Compare commits

...
Author SHA1 Message Date
tdgao 2592eb3f32 fix: TOCTOU issue 2026-04-22 08:51:49 -06:00
tdgao 5c6484dec6 Merge branch 'boris/dev-908-backend-changes' into truman/auth-for-coppa-w-be 2026-04-17 17:17:58 -06:00
tdgao f75f4d0e37 Merge branch 'main' into truman/auth-for-coppa-w-be 2026-04-17 17:13:33 -06:00
tdgao c54906e6de Merge branch 'main' into boris/dev-908-backend-changes 2026-04-17 17:08:04 -06:00
tdgao 2d8c66186c feat: implement under 13 DOB guard and email/password validation route 2026-04-17 16:54:44 -06:00
tdgao a5ccfab01f implement create user validation endpoint and add more specific error responses 2026-04-17 16:52:02 -06:00
tdgao e72a7adb76 Merge branch 'boris/dev-908-backend-changes' into truman/auth-for-coppa-w-be 2026-04-17 10:01:31 -06:00
tdgao 57b4f21080 fix: backend response for create oauth account 2026-04-17 09:45:32 -06:00
tdgao ddceea9046 remove hard coded username 2026-04-17 09:41:53 -06:00
tdgao 695817d61d fix checkbox 2026-04-17 09:30:27 -06:00
tdgao 71f7cf7f40 fix create account flow 2026-04-17 09:25:47 -06:00
tdgao 3af4c7de9a Merge branch 'truman/auth-for-coppa' into truman/auth-for-coppa-w-be 2026-04-16 11:16:35 -06:00
aecsocket 66f3c39c13 fix flow 2026-04-14 19:24:30 +01:00
aecsocket 7484afa18e Revert "Use user-provided callback addr instead of SELF_ADDR"
This reverts commit 7ea0635d86.
2026-04-14 19:16:14 +01:00
aecsocket 7ea0635d86 Use user-provided callback addr instead of SELF_ADDR 2026-04-14 18:20:14 +01:00
aecsocket fe3aba52ab improve URL-related OAuth code 2026-04-14 12:35:13 +01:00
tdgao c1696e0d9a feat: initial implementation of new sign-in oauth 2026-04-13 15:05:30 -06:00
tdgao c0fd7bebbd fix: auth pages height 2026-04-13 13:58:54 -06:00
tdgao 813a62d89d refactor: componentize auth pages 2026-04-13 13:48:29 -06:00
tdgao 6b73118cfc refactor: auth.js to auth.ts 2026-04-13 13:37:20 -06:00
tdgao 202bb20286 Merge branch 'main' into truman/auth-for-coppa 2026-04-13 11:52:00 -06:00
aecsocket 64f87551ff fix up oauth flow routes 2026-04-13 14:19:22 +01:00
aecsocket c8a586c6f1 Backend routes for choosing username in OAuth flow 2026-04-12 21:38:04 +01:00
tdgao 7c4b4d97dc update auth with new designs 2026-03-25 09:40:05 -06:00
35 changed files with 1918 additions and 860 deletions
+2
View File
@@ -13,6 +13,8 @@ import { I18nDebugPanel, NotificationPanel } from '@modrinth/ui'
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
import { setupProviders } from '~/providers/setup.ts'
import { useAuth } from './composables/auth'
const auth = await useAuth()
setupProviders(auth)
</script>
@@ -0,0 +1,266 @@
<template>
<div
class="shadow-card mx-auto flex w-full max-w-[30rem] flex-col gap-6 rounded-2xl border border-button-bg bg-surface-3 p-6"
>
<h1
class="mx-auto my-0 flex w-full justify-center text-center text-2xl font-semibold text-contrast"
>
{{ formatMessage(messages.title) }}
</h1>
<section v-if="requiresDob" class="flex flex-col gap-2.5">
<label class="text-md font-semibold text-contrast" for="create-account-dob">
{{ formatMessage(messages.dateOfBirthLabel) }}
</label>
<input
id="create-account-dob"
v-model="dateOfBirthModel"
class="scheme-dark w-full border-0 bg-surface-4 text-lg text-primary outline-none [color-scheme:dark]"
type="date"
:max="maxBirthDate"
/>
<div>
{{ formatMessage(messages.over13HelperText) }}
</div>
<Admonition :type="'info'">
<template #header>
<div class="-mb-2 flex flex-col gap-1.5 font-normal leading-normal">
<div>
{{ formatMessage(messages.infoPanelText) }}
</div>
<a
class="w-fit text-link underline"
:href="sourceCodeUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ formatMessage(messages.relevantSourceCodeText) }}
</a>
</div>
</template>
</Admonition>
</section>
<section class="flex flex-col gap-2.5">
<label class="text-md font-semibold text-contrast" for="create-account-username">
{{ formatMessage(messages.usernameOptionalLabel) }}
<span class="font-normal text-primary">(optional)</span>
</label>
<StyledInput
id="create-account-username"
v-model="usernameModel"
type="text"
:placeholder="formatMessage(messages.usernamePlaceholder)"
wrapper-class="w-full"
/>
</section>
<section class="flex flex-col gap-2.5" v-if="globals?.captcha_enabled">
<label class="text-md font-semibold text-contrast">{{
formatMessage(messages.securityCheckLabel)
}}</label>
<HCaptcha v-if="globals?.captcha_enabled" :ref="onSetCaptchaRef" v-model="tokenModel" />
</section>
<div class="flex gap-2.5 rounded-2xl border border-solid border-surface-5 p-3">
<Checkbox
v-model="subscribeModel"
class="text-left leading-snug text-primary transition-all hover:brightness-100"
:label="formatMessage(messages.subscribeLabel)"
:description="formatMessage(messages.subscribeLabel)"
/>
</div>
<ButtonStyled color="brand">
<button
class="!w-full font-bold"
:disabled="globals?.captcha_enabled ? !tokenModel : false"
@click="onCompleteSignUpClick"
>
{{ formatMessage(messages.completeSignUpButton) }}
</button>
</ButtonStyled>
</div>
</template>
<script setup>
import {
Admonition,
ButtonStyled,
Checkbox,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
const props = defineProps({
dateOfBirth: {
type: String,
default: '',
},
username: {
type: String,
default: '',
},
token: {
type: String,
default: '',
},
subscribe: {
type: Boolean,
default: false,
},
globals: {
type: Object,
default: null,
},
requiresDob: {
type: Boolean,
default: true,
},
sourceCodeUrl: {
type: String,
default:
'https://github.com/modrinth/code/blob/main/apps/frontend/src/components/ui/auth/CreateAccount.vue',
},
onCompleteSignUp: {
type: Function,
default: () => {},
},
onSetCaptchaRef: {
type: Function,
default: undefined,
},
})
const emit = defineEmits([
'update:dateOfBirth',
'update:username',
'update:token',
'update:subscribe',
])
const dateOfBirthModel = computed({
get: () => props.dateOfBirth,
set: (value) => emit('update:dateOfBirth', value),
})
const usernameModel = computed({
get: () => props.username,
set: (value) => emit('update:username', value),
})
const tokenModel = computed({
get: () => props.token,
set: (value) => emit('update:token', value),
})
const subscribeModel = computed({
get: () => props.subscribe,
set: (value) => emit('update:subscribe', value),
})
const maxBirthDate = computed(() => {
const date = new Date()
date.setFullYear(date.getFullYear() - 13)
return date.toISOString().slice(0, 10)
})
const isDateOfBirthMissing = computed(() => props.requiresDob && dateOfBirthModel.value === '')
const isUnder13 = computed(
() =>
props.requiresDob &&
dateOfBirthModel.value !== '' &&
dateOfBirthModel.value > maxBirthDate.value,
)
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
function onCompleteSignUpClick() {
if (isDateOfBirthMissing.value) {
addNotification({
title: formatMessage(messages.dateOfBirthRequiredTitle),
text: formatMessage(messages.dateOfBirthRequiredText),
type: 'warning',
})
return
}
if (isUnder13.value) {
addNotification({
title: formatMessage(messages.ageRequirementWarningTitle),
text: formatMessage(messages.under13HelperText),
type: 'error',
})
return
}
props.onCompleteSignUp()
}
const messages = defineMessages({
title: {
id: 'auth.create-account.title',
defaultMessage: 'Create an Account',
},
dateOfBirthLabel: {
id: 'auth.create-account.date-of-birth.label',
defaultMessage: 'Date of birth',
},
dateOfBirthRequiredTitle: {
id: 'auth.create-account.date-of-birth.required.title',
defaultMessage: 'Date of birth required',
},
dateOfBirthRequiredText: {
id: 'auth.create-account.date-of-birth.required.text',
defaultMessage: 'Please enter your date of birth before continuing.',
},
over13HelperText: {
id: 'auth.create-account.date-of-birth.over13-helper',
defaultMessage: 'You must be over 13 years old to use Modrinth.',
},
under13HelperText: {
id: 'auth.create-account.date-of-birth.under13-helper',
defaultMessage: 'You cannot create an account at Modrinth unless you are 13 years old.',
},
ageRequirementWarningTitle: {
id: 'auth.create-account.age-requirement.warning-title',
defaultMessage: 'Age requirement',
},
infoPanelText: {
id: 'auth.create-account.info-panel.text',
defaultMessage:
'We do not store your date of birth, it is only used to confirm your age at sign up.',
},
relevantSourceCodeText: {
id: 'auth.create-account.info-panel.source-code-link',
defaultMessage: 'Relevant source code',
},
usernameOptionalLabel: {
id: 'auth.create-account.username.optional-label',
defaultMessage: 'Username',
},
usernamePlaceholder: {
id: 'auth.create-account.username.placeholder',
defaultMessage: 'Enter username',
},
securityCheckLabel: {
id: 'auth.create-account.security-check.label',
defaultMessage: 'Security check',
},
subscribeLabel: {
id: 'auth.create-account.subscribe.label',
defaultMessage: 'Keep me updated on the cool things Modrinth is working on via email',
},
completeSignUpButton: {
id: 'auth.create-account.complete-sign-up',
defaultMessage: 'Complete sign up',
},
})
</script>
@@ -0,0 +1,295 @@
<template>
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
{{ formatMessage(messages.twoFactorCodeLabelDescription) }}
</span>
</label>
<StyledInput
id="two-factor-code"
v-model="twoFactorCodeModel"
:maxlength="11"
inputmode="numeric"
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
autocomplete="one-time-code"
@keyup.enter="onTwoFactorSignIn()"
/>
<button class="btn btn-primary continue-btn" @click="onTwoFactorSignIn()">
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
</template>
<template v-else>
<div class="flex flex-col gap-6">
<div class="text-center text-2xl font-semibold text-contrast">
{{ formatMessage(messages.signInWithLabel) }}
</div>
<section class="flex flex-col gap-2.5">
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('google', redirectTarget)">
<GoogleColorIcon />
<span class="ml-1">{{
formatMessage(messages.continueWithProvider, { provider: 'Google' })
}}</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('microsoft', redirectTarget)">
<MicrosoftColorIcon />
<span class="ml-1">{{
formatMessage(messages.continueWithProvider, { provider: 'Microsoft' })
}}</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('discord', redirectTarget)">
<DiscordColorIcon />
<span class="ml-1">{{
formatMessage(messages.continueWithProvider, { provider: 'Discord' })
}}</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('github', redirectTarget)">
<GitHubColorIcon />
<span class="ml-1">{{
formatMessage(messages.continueWithProvider, { provider: 'GitHub' })
}}</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('gitlab', redirectTarget)">
<GitLabColorIcon />
<span class="ml-1">{{
formatMessage(messages.continueWithProvider, { provider: 'GitLab' })
}}</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('steam', redirectTarget)">
<SteamColorIcon />
<span class="ml-1">{{
formatMessage(messages.continueWithProvider, { provider: 'Steam' })
}}</span>
</a>
</ButtonStyled>
</section>
<div class="h-px w-full bg-surface-5"></div>
<section class="auth-form">
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
<StyledInput
id="email"
v-model="emailModel"
:icon="MailIcon"
type="text"
inputmode="email"
autocomplete="username"
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
wrapper-class="w-full"
/>
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="password"
v-model="passwordModel"
:icon="KeyIcon"
type="password"
autocomplete="current-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<HCaptcha
v-if="globals?.captcha_enabled && emailModel && passwordModel"
:ref="onSetCaptchaRef"
v-model="tokenModel"
/>
<ButtonStyled color="brand">
<button
class="!w-full"
:disabled="globals?.captcha_enabled ? !tokenModel : false"
@click="onPasswordSignIn()"
>
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
</button>
</ButtonStyled>
<div class="auth-form__additional-options !text-base">
<IntlFormatted :message-id="messages.additionalOptionsLabel">
<template #forgot-password-link="{ children }">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: routeQuery,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
<template #create-account-link="{ children }">
<NuxtLink
class="inline text-link"
:to="{
path: '/auth/sign-up',
query: routeQuery,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</div>
</section>
</div>
</template>
</div>
</template>
<script setup>
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SteamColorIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
import { getAuthUrl } from '@/composables/auth.ts'
const props = defineProps({
subtleLauncherRedirectUri: {
type: String,
default: '',
},
flow: {
default: '',
},
redirectTarget: {
type: String,
default: '',
},
routeQuery: {
type: Object,
default: () => ({}),
},
globals: {
type: Object,
default: null,
},
email: {
type: String,
default: '',
},
password: {
type: String,
default: '',
},
token: {
type: String,
default: '',
},
twoFactorCode: {
default: null,
},
onPasswordSignIn: {
type: Function,
default: () => {},
},
onTwoFactorSignIn: {
type: Function,
default: () => {},
},
onSetCaptchaRef: {
type: Function,
default: undefined,
},
})
const emit = defineEmits([
'update:email',
'update:password',
'update:token',
'update:twoFactorCode',
])
const emailModel = computed({
get: () => props.email,
set: (value) => emit('update:email', value),
})
const passwordModel = computed({
get: () => props.password,
set: (value) => emit('update:password', value),
})
const tokenModel = computed({
get: () => props.token,
set: (value) => emit('update:token', value),
})
const twoFactorCodeModel = computed({
get: () => props.twoFactorCode,
set: (value) => emit('update:twoFactorCode', value),
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
additionalOptionsLabel: {
id: 'auth.sign-in.additional-options',
defaultMessage:
"<forgot-password-link>Forgot password</forgot-password-link> • Don't have an account? <create-account-link>Sign up</create-account-link>",
},
signInWithLabel: {
id: 'auth.sign-in.sign-in-with',
defaultMessage: 'Sign into Modrinth',
},
twoFactorCodeInputPlaceholder: {
id: 'auth.sign-in.2fa.placeholder',
defaultMessage: 'Enter code...',
},
twoFactorCodeLabel: {
id: 'auth.sign-in.2fa.label',
defaultMessage: 'Enter two-factor code',
},
twoFactorCodeLabelDescription: {
id: 'auth.sign-in.2fa.description',
defaultMessage: 'Please enter a two-factor code to proceed.',
},
continueWithProvider: {
id: 'auth.continue-with-provider',
defaultMessage: 'Continue with {provider}',
},
continueWithEmail: {
id: 'auth.sign-in.continue-with-email',
defaultMessage: 'Continue with Email',
},
})
</script>
@@ -0,0 +1,223 @@
<template>
<div class="flex flex-col gap-6">
<div class="text-center text-2xl font-semibold text-contrast">
{{ formatMessage(messages.signUpWithTitle) }}
</div>
<section class="flex flex-col gap-2.5">
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('google', redirectTarget)">
<GoogleColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Google' }) }}</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('microsoft', redirectTarget)">
<MicrosoftColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Microsoft' }) }}</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('discord', redirectTarget)">
<DiscordColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Discord' }) }}</span>
</a>
</ButtonStyled>
<template v-if="showOtherOptions">
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('github', redirectTarget)">
<GitHubColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'GitHub' }) }}</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('gitlab', redirectTarget)">
<GitLabColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'GitLab' }) }}</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a class="!shadow-none" :href="getAuthUrl('steam', redirectTarget)">
<SteamColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Steam' }) }}</span>
</a>
</ButtonStyled>
</template>
<button
class="mx-auto -mb-3 bg-transparent pt-1 text-center text-base font-semibold text-secondary transition-all hover:text-primary"
@click="onToggleOtherOptions()"
>
{{
showOtherOptions
? formatMessage(messages.showFewerOptions)
: formatMessage(messages.showOtherOptions)
}}
</button>
</section>
<div class="h-px w-full bg-surface-5"></div>
<section class="flex flex-col gap-2.5">
<label for="email" hidden>{{ formatMessage(commonMessages.emailLabel) }}</label>
<StyledInput
id="email"
v-model="emailModel"
:icon="MailIcon"
type="email"
autocomplete="email"
:placeholder="formatMessage(commonMessages.emailLabel)"
wrapper-class="w-full"
/>
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="password"
v-model="passwordModel"
:icon="KeyIcon"
type="password"
autocomplete="new-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<p v-if="!routeQuery.launcher">
<IntlFormatted :message-id="messages.legalDisclaimer">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-policy-link="{ children }">
<NuxtLink to="/legal/privacy" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</p>
<ButtonStyled color="brand">
<button
class="!w-full"
:disabled="!emailModel || !passwordModel"
@click="onContinueWithEmail()"
>
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
</button>
</ButtonStyled>
<div class="auth-form__additional-options">
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-in',
query: routeQuery,
}"
>
{{ formatMessage(commonMessages.signInButton) }}
</NuxtLink>
</div>
</section>
</div>
</template>
<script setup>
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SteamColorIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import { getAuthUrl } from '@/composables/auth.ts'
const props = defineProps({
redirectTarget: {
type: String,
default: '',
},
showOtherOptions: {
type: Boolean,
default: false,
},
routeQuery: {
type: Object,
default: () => ({}),
},
email: {
type: String,
default: '',
},
password: {
type: String,
default: '',
},
onToggleOtherOptions: {
type: Function,
default: () => {},
},
onContinueWithEmail: {
type: Function,
default: () => {},
},
})
const emit = defineEmits(['update:email', 'update:password'])
const emailModel = computed({
get: () => props.email,
set: (value) => emit('update:email', value),
})
const passwordModel = computed({
get: () => props.password,
set: (value) => emit('update:password', value),
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
signUpWithTitle: {
id: 'auth.sign-up.title.sign-up-with',
defaultMessage: 'Create an Account',
},
continueWithProvider: {
id: 'auth.continue-with-provider',
defaultMessage: 'Continue with {provider}',
},
legalDisclaimer: {
id: 'auth.sign-up.legal-dislaimer',
defaultMessage:
"By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
},
alreadyHaveAccountLabel: {
id: 'auth.sign-up.sign-in-option.title',
defaultMessage: 'Already have an account?',
},
continueWithEmail: {
id: 'auth.sign-up.continue-with-email',
defaultMessage: 'Continue with Email',
},
showFewerOptions: {
id: 'auth.sign-up.show-fewer-options',
defaultMessage: 'Show fewer options',
},
showOtherOptions: {
id: 'auth.sign-up.show-other-options',
defaultMessage: 'Show other options',
},
})
</script>
@@ -164,9 +164,7 @@ defineExpose({
show,
})
const auth = (await useAuth()) as Ref<{
user: { id: string; username: string; avatar_url: string } | null
}>
const auth = await useAuth()
const messages = defineMessages({
title: {
@@ -120,7 +120,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js'
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.ts'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } =
@@ -193,7 +193,6 @@ async function saveVenmoHandle() {
},
})
// @ts-expect-error auth.js is not typed
await useAuth(auth.value.token)
initialVenmoHandle.value = venmoHandle.value.trim()
@@ -358,8 +358,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
import { useAuth } from '@/composables/auth.js'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { useAuth } from '~/composables/auth.ts'
const debug = useDebugLogger('TremendousDetailsStage')
const { withdrawData, maxWithdrawAmount, availableMethods, paymentOptions, calculateFees } =
@@ -190,7 +190,6 @@ import {
LinkIcon,
} from '@modrinth/assets'
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
import { type OverflowMenuOption, useFormatDateTime } from '@modrinth/ui'
import {
Avatar,
ButtonStyled,
@@ -198,6 +197,8 @@ import {
getProjectTypeIcon,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useFormatDateTime,
useRelativeTime,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
@@ -26,11 +26,11 @@ import {
getProjectTypeIcon,
injectModrinthClient,
injectNotificationManager,
NavTabs,
OverflowMenu,
type OverflowMenuOption,
useFormatDateTime,
} from '@modrinth/ui'
import { NavTabs } from '@modrinth/ui'
import {
capitalizeString,
formatProjectType,
@@ -87,7 +87,7 @@ const currentProjectId = computed(() => projectV3.value?.id)
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const auth = (await useAuth()) as { user?: { id: string } }
const auth = await useAuth()
interface VersionInfo {
id: string
-157
View File
@@ -1,157 +0,0 @@
export const useAuth = async (oldToken = null) => {
const auth = useState('auth', () => ({
user: null,
token: '',
headers: {},
}))
if (!auth.value.user || oldToken) {
auth.value = await initAuth(oldToken)
}
return auth
}
export const initAuth = async (oldToken = null) => {
const auth = {
user: null,
token: '',
}
if (oldToken === 'none') {
return auth
}
const route = useRoute()
const authCookie = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
httpOnly: false,
path: '/',
})
if (oldToken) {
authCookie.value = oldToken
}
if (route.query.code && !route.fullPath.includes('new_account=true')) {
authCookie.value = route.query.code
}
if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') {
const redirect = route.path.startsWith('/auth/') ? null : route.fullPath
await navigateTo(
`/auth/welcome?authToken=${route.query.code}${
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''
}`,
)
}
if (authCookie.value) {
auth.token = authCookie.value
if (!auth.token || !auth.token.startsWith('mra_')) {
return auth
}
try {
auth.user = await useBaseFetch(
'user',
{
headers: {
Authorization: auth.token,
},
},
true,
)
} catch {
/* empty */
}
}
if (!auth.user && auth.token) {
try {
const session = await useBaseFetch(
'session/refresh',
{
method: 'POST',
headers: {
Authorization: auth.token,
},
},
true,
)
auth.token = session.session
authCookie.value = auth.token
auth.user = await useBaseFetch(
'user',
{
headers: {
Authorization: auth.token,
},
},
true,
)
} catch {
authCookie.value = null
}
}
return auth
}
export const getSignInRedirectPath = (route) => {
const fullPath = route.fullPath
if (fullPath === '/auth' || fullPath.startsWith('/auth/')) {
return '/dashboard'
}
return fullPath
}
export const getSignInRouteObj = (route, redirectOverride) => ({
path: '/auth/sign-in',
query: {
redirect: redirectOverride ?? getSignInRedirectPath(route),
},
})
export const getAuthUrl = (provider, redirect = '/dashboard') => {
const config = useRuntimeConfig()
const route = useNativeRoute()
const fullURL = route.query.launcher
? getLauncherRedirectUrl(route)
: `${config.public.siteUrl}/auth/sign-in?redirect=${encodeURIComponent(redirect)}`
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`
}
export const removeAuthProvider = async (provider) => {
startLoading()
const auth = await useAuth()
await useBaseFetch('auth/provider', {
method: 'DELETE',
body: {
provider,
},
})
await useAuth(auth.value.token)
stopLoading()
}
export const getLauncherRedirectUrl = (route) => {
const usesLocalhostRedirectionScheme =
['4', '6'].includes(route.query.ipver) && Number(route.query.port) < 65536
return usesLocalhostRedirectionScheme
? `http://${route.query.ipver === '4' ? '127.0.0.1' : '[::1]'}:${route.query.port}`
: `https://launcher-files.modrinth.com`
}
+172
View File
@@ -0,0 +1,172 @@
import type { Labrinth } from '@modrinth/api-client'
import type { LocationQueryValue, RouteLocationNormalizedLoaded } from 'vue-router'
import type { CookieOptions } from '#app'
type AuthState = {
user: Labrinth.Users.v2.User | null
token: string
}
type QueryValue = LocationQueryValue | LocationQueryValue[] | undefined
type FullPathRoute = Pick<RouteLocationNormalizedLoaded, 'fullPath'>
type LauncherRoute = Pick<RouteLocationNormalizedLoaded, 'query'>
const AUTH_COOKIE_OPTIONS = {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
httpOnly: false,
path: '/',
} satisfies CookieOptions<string | null>
const getQueryString = (value: QueryValue) => {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
export const useAuth = async (oldToken: string | null | undefined = null) => {
const auth = useState<AuthState>('auth', () => ({
user: null,
token: '',
}))
if (!auth.value.user || oldToken) {
auth.value = await initAuth(oldToken)
}
return auth
}
export const initAuth = async (oldToken: string | null | undefined = null) => {
const auth: AuthState = {
user: null,
token: '',
}
if (oldToken === 'none') {
return auth
}
const route = useRoute()
const authCookie = useCookie<string | null>('auth-token', AUTH_COOKIE_OPTIONS)
const authCode = getQueryString(route.query.code)
if (oldToken) {
authCookie.value = oldToken
}
if (authCode) {
authCookie.value = authCode
}
if (authCookie.value) {
auth.token = authCookie.value
if (!auth.token || !auth.token.startsWith('mra_')) {
return auth
}
try {
auth.user = (await useBaseFetch(
'user',
{
headers: {
Authorization: auth.token,
},
},
true,
)) as Labrinth.Users.v2.User
} catch {
/* empty */
}
}
if (!auth.user && auth.token) {
try {
const session = (await useBaseFetch(
'session/refresh',
{
method: 'POST',
headers: {
Authorization: auth.token,
},
},
true,
)) as { session: string }
auth.token = session.session
authCookie.value = auth.token
auth.user = (await useBaseFetch(
'user',
{
headers: {
Authorization: auth.token,
},
},
true,
)) as Labrinth.Users.v2.User
} catch {
authCookie.value = null
}
}
return auth
}
export const getSignInRedirectPath = (route: FullPathRoute) => {
const fullPath = route.fullPath
if (fullPath === '/auth' || fullPath.startsWith('/auth/')) {
return '/dashboard'
}
return fullPath
}
export const getSignInRouteObj = (route: FullPathRoute, redirectOverride?: string | null) => ({
path: '/auth/sign-in',
query: {
redirect: redirectOverride ?? getSignInRedirectPath(route),
},
})
export const getAuthUrl = (provider: string, redirect = '/dashboard') => {
const config = useRuntimeConfig()
const route = useNativeRoute()
const launcher = getQueryString(route.query.launcher)
const fullURL = launcher
? getLauncherRedirectUrl(route)
: `${config.public.siteUrl}/auth/sign-in?redirect=${encodeURIComponent(redirect)}`
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`
}
export const removeAuthProvider = async (provider: string) => {
startLoading()
const auth = await useAuth()
await useBaseFetch('auth/provider', {
method: 'DELETE',
body: {
provider,
},
})
await useAuth(auth.value.token)
stopLoading()
}
export const getLauncherRedirectUrl = (route: LauncherRoute) => {
const ipver = getQueryString(route.query.ipver)
const port = Number(getQueryString(route.query.port))
const usesLocalhostRedirectionScheme = ['4', '6'].includes(ipver ?? '') && port < 65536
return usesLocalhostRedirectionScheme
? `http://${ipver === '4' ? '127.0.0.1' : '[::1]'}:${port}`
: 'https://launcher-files.modrinth.com'
}
+1 -1
View File
@@ -762,7 +762,7 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { errors as generatedStateErrors } from '~/generated/state.json'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
+16 -13
View File
@@ -224,6 +224,9 @@
"auth.authorize.redirect-url": {
"message": "You will be redirected to <redirect-url>{url}</redirect-url>"
},
"auth.continue-with-provider": {
"message": "Continue with {provider}"
},
"auth.reset-password.method-choice.action": {
"message": "Send recovery email"
},
@@ -267,25 +270,28 @@
"message": "Enter code..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Forgot password?</forgot-password-link> • <create-account-link>Create an account</create-account-link>"
"message": "<forgot-password-link>Forgot password</forgot-password-link> • Don't have an account? <create-account-link>Sign up</create-account-link>"
},
"auth.sign-in.continue-with-email": {
"message": "Continue with Email"
},
"auth.sign-in.sign-in-with": {
"message": "Sign in with"
"message": "Sign into Modrinth"
},
"auth.sign-in.title": {
"message": "Sign In"
},
"auth.sign-in.use-password": {
"message": "Or use a password"
},
"auth.sign-up.action.create-account": {
"message": "Create account"
"auth.sign-up.continue-with-email": {
"message": "Continue with Email"
},
"auth.sign-up.legal-dislaimer": {
"message": "By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Passwords do not match!"
"auth.sign-up.show-fewer-options": {
"message": "Show fewer options"
},
"auth.sign-up.show-other-options": {
"message": "Show other options"
},
"auth.sign-up.sign-in-option.title": {
"message": "Already have an account?"
@@ -296,11 +302,8 @@
"auth.sign-up.title": {
"message": "Sign Up"
},
"auth.sign-up.title.create-account": {
"message": "Or create an account yourself"
},
"auth.sign-up.title.sign-up-with": {
"message": "Sign up with"
"message": "Create an Account"
},
"auth.verify-email.action.account-settings": {
"message": "Account settings"
+1 -1
View File
@@ -1140,7 +1140,7 @@ import MessageBanner from '~/components/ui/MessageBanner.vue'
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
@@ -308,7 +308,7 @@ import {
import { useTemplateRef } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { reportVersion } from '~/utils/report-helpers.ts'
const route = useRoute()
@@ -443,7 +443,7 @@ import { formatBytes, renderHighlightedString } from '@modrinth/utils'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import Modal from '~/components/ui/Modal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { useImageUpload } from '~/composables/image-upload.ts'
import { inferVersionInfo } from '~/helpers/infer'
import { createDataPackVersion } from '~/helpers/package.js'
@@ -269,7 +269,7 @@ import {
import { onMounted, useTemplateRef } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { reportVersion } from '~/utils/report-helpers.ts'
const route = useRoute()
+5 -22
View File
@@ -8,14 +8,15 @@ useSeoMeta({
})
</script>
<template>
<NuxtPage class="auth-container universal-card" />
<div class="grid min-h-[calc(100vh-4.5rem)] place-items-center pb-20">
<NuxtPage class="auth-container universal-card border border-solid border-surface-5" />
</div>
</template>
<style>
.auth-container {
width: 26rem;
width: 28rem;
max-width: calc(100% - 2rem);
margin: 1rem auto;
display: flex;
flex-direction: column;
gap: 2rem;
@@ -45,24 +46,6 @@ useSeoMeta({
margin: 0 0 0 0.5rem;
}
.third-party {
display: grid;
gap: var(--gap-md);
grid-template-columns: repeat(2, 1fr);
width: 100%;
}
.third-party .btn {
width: 100%;
vertical-align: middle;
}
.third-party .btn svg {
margin-right: var(--gap-sm);
width: 1.25rem;
height: 1.25rem;
}
@media screen and (max-width: 25.5rem) {
.third-party .btn {
grid-column: 1 / 3;
@@ -84,7 +67,7 @@ useSeoMeta({
align-items: center;
display: flex;
justify-content: center;
gap: var(--gap-md);
gap: var(--gap-xs);
flex-wrap: wrap;
}
@@ -95,7 +95,6 @@ import {
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { useAuth } from '@/composables/auth.js'
import { useScopes } from '@/composables/auth/scopes.ts'
const client = injectModrinthClient()
@@ -0,0 +1,156 @@
<template>
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<CreateAccountView
v-else
v-model:date-of-birth="dateOfBirth"
v-model:username="username"
v-model:token="token"
v-model:subscribe="subscribe"
:globals="globals"
:requires-dob="requiresDob"
:on-complete-sign-up="completeOAuthSignUp"
:on-set-captcha-ref="setCaptchaRef"
/>
</template>
<script setup>
import { commonMessages, defineMessages, injectModrinthClient, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import CreateAccountView from '@/components/ui/auth/CreateAccount.vue'
import { getLauncherRedirectUrl } from '@/composables/auth.ts'
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const route = useNativeRoute()
const auth = await useAuth()
const messages = defineMessages({
createAccountTitle: {
id: 'auth.create-account.page-title',
defaultMessage: 'Create Account',
},
})
useHead({
title() {
return `${formatMessage(messages.createAccountTitle)} - Modrinth`
},
})
const requiresDob = computed(() => {
const raw = route.query.requires_dob
const value = Array.isArray(raw) ? raw[0] : raw
if (!value) {
return false
}
return value === 'true' || value === '1'
})
const oauthFlowState = computed(() => {
const state = route.query.state
const value = Array.isArray(state) ? state[0] : state
return typeof value === 'string' ? value : ''
})
const defaultUsername = computed(() => {
const queryUsername = route.query.username
const value = Array.isArray(queryUsername) ? queryUsername[0] : queryUsername
return typeof value === 'string' && value.length > 0 ? value : ''
})
const dateOfBirth = ref('')
const username = ref(defaultUsername.value)
const token = ref('')
const subscribe = ref(false)
const subtleLauncherRedirectUri = ref()
const captcha = ref()
const setCaptchaRef = (captchaRef) => {
captcha.value = captchaRef
}
const { data: globals } = useQuery({
queryKey: ['auth-globals'],
queryFn: async () => {
try {
return await client.labrinth.globals_internal.get()
} catch (err) {
console.error('Error fetching globals:', err)
return { captcha_enabled: true, tax_compliance_thresholds: {} }
}
},
})
async function completeOAuthSignUp() {
startLoading()
try {
if (!oauthFlowState.value) {
throw new Error('Missing OAuth flow state')
}
const res = await client.labrinth.auth_v2.createOAuthAccount({
username: username.value.trim() || defaultUsername.value,
state: oauthFlowState.value,
challenge: token.value,
sign_up_newsletter: subscribe.value,
})
await finishSignIn(res.session)
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
captcha.value?.reset()
}
stopLoading()
}
async function finishSignIn(sessionToken) {
if (route.query.launcher) {
let token = sessionToken
if (!token) {
token = auth.value.token
}
const redirectUrl = `${getLauncherRedirectUrl(route)}/?code=${token}`
if (redirectUrl.startsWith('https://launcher-files.modrinth.com/')) {
await navigateTo(redirectUrl, {
external: true,
})
} else {
subtleLauncherRedirectUri.value = redirectUrl
}
return
}
if (sessionToken) {
await useAuth(sessionToken)
await useUser()
queryClient.clear()
}
if (route.query.redirect) {
const redirect = decodeURIComponent(route.query.redirect)
await navigateTo(redirect, {
replace: true,
})
} else {
await navigateTo('/dashboard')
}
}
</script>
@@ -76,7 +76,7 @@ import {
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import HCaptcha from '@/components/ui/HCaptcha.vue'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
+32 -164
View File
@@ -1,154 +1,32 @@
<template>
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
{{ formatMessage(messages.twoFactorCodeLabelDescription) }}
</span>
</label>
<StyledInput
id="two-factor-code"
v-model="twoFactorCode"
:maxlength="11"
inputmode="numeric"
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
autocomplete="one-time-code"
@keyup.enter="begin2FASignIn"
/>
<button class="btn btn-primary continue-btn" @click="begin2FASignIn">
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
</template>
<template v-else>
<h1>{{ formatMessage(messages.signInWithLabel) }}</h1>
<section class="third-party">
<a class="btn" :href="getAuthUrl('discord', redirectTarget)">
<DiscordColorIcon />
<span>Discord</span>
</a>
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
<GitHubColorIcon />
<span>GitHub</span>
</a>
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
<MicrosoftColorIcon />
<span>Microsoft</span>
</a>
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
<GoogleColorIcon />
<span>Google</span>
</a>
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
<SteamColorIcon />
<span>Steam</span>
</a>
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
<GitLabColorIcon />
<span>GitLab</span>
</a>
</section>
<h1>{{ formatMessage(messages.usePasswordLabel) }}</h1>
<section class="auth-form">
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
<StyledInput
id="email"
v-model="email"
:icon="MailIcon"
type="text"
inputmode="email"
autocomplete="username"
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
wrapper-class="w-full"
/>
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="password"
v-model="password"
:icon="KeyIcon"
type="password"
autocomplete="current-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
<button
class="btn btn-primary continue-btn centered-btn"
:disabled="globals?.captcha_enabled ? !token : false"
@click="beginPasswordSignIn()"
>
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
<div class="auth-form__additional-options">
<IntlFormatted :message-id="messages.additionalOptionsLabel">
<template #forgot-password-link="{ children }">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
<template #create-account-link="{ children }">
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-up',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</div>
</section>
</template>
</div>
<SignInView
v-model:email="email"
v-model:password="password"
v-model:token="token"
v-model:two-factor-code="twoFactorCode"
:subtle-launcher-redirect-uri="subtleLauncherRedirectUri"
:flow="flow"
:redirect-target="redirectTarget"
:route-query="route.query"
:globals="globals"
:on-password-sign-in="beginPasswordSignIn"
:on-two-factor-sign-in="begin2FASignIn"
:on-set-captcha-ref="setCaptchaRef"
/>
</template>
<script setup>
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SteamColorIcon,
} from '@modrinth/assets'
import {
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import HCaptcha from '@/components/ui/HCaptcha.vue'
import { getAuthUrl, getLauncherRedirectUrl } from '@/composables/auth.js'
import SignInView from '@/components/ui/auth/SignIn.vue'
import { getLauncherRedirectUrl } from '@/composables/auth.ts'
const client = injectModrinthClient()
const queryClient = useQueryClient()
@@ -156,35 +34,10 @@ const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
additionalOptionsLabel: {
id: 'auth.sign-in.additional-options',
defaultMessage:
'<forgot-password-link>Forgot password?</forgot-password-link> • <create-account-link>Create an account</create-account-link>',
},
signInWithLabel: {
id: 'auth.sign-in.sign-in-with',
defaultMessage: 'Sign in with',
},
signInTitle: {
id: 'auth.sign-in.title',
defaultMessage: 'Sign In',
},
twoFactorCodeInputPlaceholder: {
id: 'auth.sign-in.2fa.placeholder',
defaultMessage: 'Enter code...',
},
twoFactorCodeLabel: {
id: 'auth.sign-in.2fa.label',
defaultMessage: 'Enter two-factor code',
},
twoFactorCodeLabelDescription: {
id: 'auth.sign-in.2fa.description',
defaultMessage: 'Please enter a two-factor code to proceed.',
},
usePasswordLabel: {
id: 'auth.sign-in.use-password',
defaultMessage: 'Or use a password',
},
})
useHead({
@@ -196,10 +49,22 @@ useHead({
const auth = await useAuth()
const route = useNativeRoute()
if (route.query.state !== undefined) {
await navigateTo(
{
path: '/auth/create/oauth',
query: route.query,
},
{
replace: true,
},
)
}
const redirectTarget = route.query.redirect || ''
const subtleLauncherRedirectUri = ref()
if (route.query.code && !route.fullPath.includes('new_account=true')) {
if (route.query.code) {
await finishSignIn()
}
@@ -208,6 +73,9 @@ if (auth.value.user) {
}
const captcha = ref()
const setCaptchaRef = (captchaRef) => {
captcha.value = captchaRef
}
const { data: globals } = useQuery({
queryKey: ['auth-globals'],
+73 -178
View File
@@ -1,156 +1,38 @@
<template>
<div>
<h1>{{ formatMessage(messages.signUpWithTitle) }}</h1>
<section class="third-party">
<a class="btn discord-btn" :href="getAuthUrl('discord', redirectTarget)">
<DiscordColorIcon />
<span>Discord</span>
</a>
<a class="btn" :href="getAuthUrl('github', redirectTarget)">
<GitHubColorIcon />
<span>GitHub</span>
</a>
<a class="btn" :href="getAuthUrl('microsoft', redirectTarget)">
<MicrosoftColorIcon />
<span>Microsoft</span>
</a>
<a class="btn" :href="getAuthUrl('google', redirectTarget)">
<GoogleColorIcon />
<span>Google</span>
</a>
<a class="btn" :href="getAuthUrl('steam', redirectTarget)">
<SteamColorIcon />
<span>Steam</span>
</a>
<a class="btn" :href="getAuthUrl('gitlab', redirectTarget)">
<GitLabColorIcon />
<span>GitLab</span>
</a>
</section>
<h1>{{ formatMessage(messages.createAccountTitle) }}</h1>
<section class="auth-form">
<label for="email" hidden>{{ formatMessage(commonMessages.emailLabel) }}</label>
<StyledInput
id="email"
v-model="email"
:icon="MailIcon"
type="email"
autocomplete="username"
:placeholder="formatMessage(commonMessages.emailLabel)"
wrapper-class="w-full"
/>
<label for="username" hidden>{{ formatMessage(commonMessages.usernameLabel) }}</label>
<StyledInput
id="username"
v-model="username"
:icon="UserIcon"
type="text"
autocomplete="username"
:placeholder="formatMessage(commonMessages.usernameLabel)"
wrapper-class="w-full"
/>
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="password"
v-model="password"
:icon="KeyIcon"
type="password"
autocomplete="new-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<label for="confirm-password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="confirm-password"
v-model="confirmPassword"
:icon="KeyIcon"
type="password"
autocomplete="new-password"
:placeholder="formatMessage(commonMessages.confirmPasswordLabel)"
wrapper-class="w-full"
/>
<Checkbox
v-model="subscribe"
class="subscribe-btn"
:label="formatMessage(messages.subscribeLabel)"
:description="formatMessage(messages.subscribeLabel)"
/>
<p v-if="!route.query.launcher">
<IntlFormatted :message-id="messages.legalDisclaimer">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-policy-link="{ children }">
<NuxtLink to="/legal/privacy" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</p>
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
<button
class="btn btn-primary continue-btn centered-btn"
:disabled="globals?.captcha_enabled ? !token : false"
@click="createAccount"
>
{{ formatMessage(messages.createAccountButton) }} <RightArrowIcon />
</button>
<div class="auth-form__additional-options">
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-in',
query: route.query,
}"
>
{{ formatMessage(commonMessages.signInButton) }}
</NuxtLink>
</div>
</section>
</div>
<SignUpView
v-if="!isCreateAccountStep"
v-model:email="email"
v-model:password="password"
:redirect-target="redirectTarget"
:show-other-options="showOtherOptions"
:route-query="route.query"
:on-toggle-other-options="toggleOtherOptions"
:on-continue-with-email="continueWithEmail"
/>
<CreateAccountView
v-else
v-model:date-of-birth="dateOfBirth"
v-model:username="username"
v-model:token="token"
v-model:subscribe="subscribe"
:globals="globals"
:on-complete-sign-up="createAccount"
:on-set-captcha-ref="setCaptchaRef"
/>
</template>
<script setup>
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SteamColorIcon,
UserIcon,
} from '@modrinth/assets'
import {
Checkbox,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import HCaptcha from '@/components/ui/HCaptcha.vue'
import { getAuthUrl } from '@/composables/auth.js'
import CreateAccountView from '@/components/ui/auth/CreateAccount.vue'
import SignUpView from '@/components/ui/auth/SignUp.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
@@ -161,30 +43,13 @@ const messages = defineMessages({
id: 'auth.sign-up.title',
defaultMessage: 'Sign Up',
},
signUpWithTitle: {
id: 'auth.sign-up.title.sign-up-with',
defaultMessage: 'Sign up with',
ageRequirementWarningTitle: {
id: 'auth.sign-up.age-requirement.warning-title',
defaultMessage: 'Age requirement',
},
createAccountTitle: {
id: 'auth.sign-up.title.create-account',
defaultMessage: 'Or create an account yourself',
},
subscribeLabel: {
id: 'auth.sign-up.subscribe.label',
defaultMessage: 'Subscribe to updates about Modrinth',
},
legalDisclaimer: {
id: 'auth.sign-up.legal-dislaimer',
defaultMessage:
"By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
},
createAccountButton: {
id: 'auth.sign-up.action.create-account',
defaultMessage: 'Create account',
},
alreadyHaveAccountLabel: {
id: 'auth.sign-up.sign-in-option.title',
defaultMessage: 'Already have an account?',
under13HelperText: {
id: 'auth.create-account.date-of-birth.under13-helper',
defaultMessage: 'You cannot create an account at Modrinth unless you are 13 years old.',
},
})
@@ -196,12 +61,20 @@ const auth = await useAuth()
const route = useNativeRoute()
const redirectTarget = route.query.redirect
const showOtherOptions = ref(false)
const isCreateAccountStep = ref(false)
if (auth.value.user) {
await navigateTo('/dashboard')
}
const captcha = ref()
const setCaptchaRef = (captchaRef) => {
captcha.value = captchaRef
}
const toggleOtherOptions = () => {
showOtherOptions.value = !showOtherOptions.value
}
const { data: globals } = useQuery({
queryKey: ['auth-globals'],
@@ -216,29 +89,51 @@ const { data: globals } = useQuery({
})
const email = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const dateOfBirth = ref('')
const username = ref('')
const token = ref('')
const subscribe = ref(false)
async function continueWithEmail() {
startLoading()
try {
const generatedUsername = generateUsernameFromEmail(email.value)
await client.labrinth.auth_v2.validateCreateAccount({
username: generatedUsername,
password: password.value,
email: email.value,
})
token.value = ''
username.value = generatedUsername
isCreateAccountStep.value = true
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
function generateUsernameFromEmail(emailAddress) {
const [localPart = ''] = emailAddress.trim().toLowerCase().split('@')
const sanitized = localPart
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
return (sanitized || 'user').slice(0, 39)
}
async function createAccount() {
startLoading()
try {
if (confirmPassword.value !== password.value) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: formatMessage({
id: 'auth.sign-up.notification.password-mismatch.text',
defaultMessage: 'Passwords do not match!',
}),
type: 'error',
})
captcha.value?.reset()
}
const res = await client.labrinth.auth_v2.createAccount({
username: username.value,
username: username.value.trim() || generateUsernameFromEmail(email.value),
password: password.value,
email: email.value,
challenge: token.value,
@@ -54,7 +54,6 @@ import { Avatar, injectModrinthClient } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
import { useAuth } from '~/composables/auth.js'
const createOrgModal = ref(null)
+1 -1
View File
@@ -478,7 +478,7 @@ import SteamIcon from 'assets/icons/auth/sso-steam.svg'
import QrcodeVue from 'qrcode.vue'
import Modal from '~/components/ui/Modal.vue'
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.js'
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.ts'
definePageMeta({
middleware: 'auth',
+1 -1
View File
@@ -521,7 +521,7 @@ import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { reportUser } from '~/utils/report-helpers.ts'
const data = useNuxtApp()
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { ref, watchEffect } from 'vue'
import { getSignInRedirectPath } from '~/composables/auth.js'
import { getSignInRedirectPath } from '~/composables/auth.ts'
export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
const router = useRouter()
+17 -3
View File
@@ -43,7 +43,13 @@ pub enum AuthenticationError {
#[error(
"User email is already registered on Modrinth. Try 'Forgot password' to access your account."
)]
DuplicateUser,
DuplicateEmail,
#[error("Username is already taken on Modrinth.")]
UsernameTaken,
#[error(
"This authentication provider is already linked to another Modrinth account."
)]
ProviderAlreadyLinked,
#[error("Invalid state sent, you probably need to get a new websocket")]
SocketError,
#[error("Invalid callback URL specified")]
@@ -73,7 +79,11 @@ impl actix_web::ResponseError for AuthenticationError {
AuthenticationError::FileHosting(..) => {
StatusCode::INTERNAL_SERVER_ERROR
}
AuthenticationError::DuplicateUser => StatusCode::BAD_REQUEST,
AuthenticationError::DuplicateEmail => StatusCode::BAD_REQUEST,
AuthenticationError::UsernameTaken => StatusCode::BAD_REQUEST,
AuthenticationError::ProviderAlreadyLinked => {
StatusCode::BAD_REQUEST
}
AuthenticationError::SocketError => StatusCode::BAD_REQUEST,
}
}
@@ -102,7 +112,11 @@ impl AuthenticationError {
AuthenticationError::InvalidClientId => "invalid_client_id",
AuthenticationError::Url => "url_error",
AuthenticationError::FileHosting(..) => "file_hosting",
AuthenticationError::DuplicateUser => "duplicate_user",
AuthenticationError::DuplicateEmail => "duplicate_email",
AuthenticationError::UsernameTaken => "username_taken",
AuthenticationError::ProviderAlreadyLinked => {
"provider_already_linked"
}
AuthenticationError::SocketError => "socket",
}
}
+30 -14
View File
@@ -1,15 +1,16 @@
use super::ids::*;
use crate::auth::AuthProvider;
use crate::auth::oauth::uris::OAuthRedirectUris;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::{auth::AuthProvider, routes::internal::flows::TempUser};
use chrono::Duration;
use rand::Rng;
use rand::distributions::Alphanumeric;
use rand_chacha::ChaCha20Rng;
use rand_chacha::rand_core::SeedableRng;
use serde::{Deserialize, Serialize};
use url::Url;
const FLOWS_NAMESPACE: &str = "flows";
@@ -18,10 +19,15 @@ const FLOWS_NAMESPACE: &str = "flows";
pub enum DBFlow {
OAuth {
user_id: Option<DBUserId>,
url: String,
url: Url,
provider: AuthProvider,
existing_user_id: Option<DBUserId>,
},
OAuthPending {
url: Url,
provider: AuthProvider,
user: TempUser,
},
Login2FA {
user_id: DBUserId,
},
@@ -55,28 +61,38 @@ pub enum DBFlow {
}
impl DBFlow {
pub async fn insert_with_state(
&self,
expires: Duration,
redis: &RedisPool,
state: &str,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
redis
.set_serialized_to_json(
FLOWS_NAMESPACE,
&state,
&self,
Some(expires.num_seconds()),
)
.await?;
Ok(())
}
pub async fn insert(
&self,
expires: Duration,
redis: &RedisPool,
) -> Result<String, DatabaseError> {
let mut redis = redis.connect().await?;
let flow = ChaCha20Rng::from_entropy()
let state = ChaCha20Rng::from_entropy()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect::<String>();
redis
.set_serialized_to_json(
FLOWS_NAMESPACE,
&flow,
&self,
Some(expires.num_seconds()),
)
.await?;
Ok(flow)
self.insert_with_state(expires, redis, &state).await?;
Ok(state)
}
pub async fn get(
+564 -288
View File
@@ -10,6 +10,7 @@ use crate::database::models::{DBUser, DBUserId};
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::error::ApiError as ApiErrorResponse;
use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
use crate::models::users::{Badges, Role};
@@ -22,6 +23,8 @@ use crate::util::error::Context;
use crate::util::ext::get_image_ext;
use crate::util::img::upload_image_optimized;
use crate::util::validate::validation_errors_to_string;
use actix_http::header::LOCATION;
use actix_web::http::StatusCode;
use actix_web::web::{Data, Query};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use argon2::password_hash::SaltString;
@@ -39,7 +42,9 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use tracing::info;
use thiserror::Error;
use tracing::{error, info};
use url::Url;
use validator::Validate;
use zxcvbn::Score;
@@ -49,6 +54,8 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
.service(init)
.service(auth_callback)
.service(delete_auth_provider)
.service(create_oauth_account)
.service(validate_create_account_with_password)
.service(create_account_with_password)
.service(login_password)
.service(login_2fa)
@@ -65,7 +72,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
);
}
#[derive(Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct TempUser {
pub id: String,
pub username: String,
@@ -85,44 +92,26 @@ impl TempUser {
client: &PgPool,
file_host: &Arc<dyn FileHost + Send + Sync>,
redis: &RedisPool,
) -> Result<crate::database::models::DBUserId, AuthenticationError> {
username: String,
sign_up_newsletter: bool,
) -> Result<DBUserId, AuthenticationError> {
if let Some(email) = &self.email
&& crate::database::models::DBUser::get_by_email(email, client)
.await?
.is_some()
{
return Err(AuthenticationError::DuplicateUser);
return Err(AuthenticationError::DuplicateEmail);
}
let user_id =
crate::database::models::generate_user_id(transaction).await?;
let mut username_increment: i32 = 0;
let mut username = None;
let existing_id = DBUser::get(&username, client, redis)
.await
.wrap_err("failed to fetch existing user by id")?;
while username.is_none() {
let test_username = format!(
"{}{}",
self.username,
if username_increment > 0 {
username_increment.to_string()
} else {
"".to_string()
}
);
let new_id = crate::database::models::DBUser::get(
&test_username,
client,
redis,
)
.await?;
if new_id.is_none() {
username = Some(test_username);
} else {
username_increment += 1;
}
if existing_id.is_some() {
return Err(AuthenticationError::UsernameTaken);
}
let (avatar_url, raw_avatar_url) = if let Some(avatar_url) =
@@ -166,89 +155,86 @@ impl TempUser {
(None, None)
};
if let Some(username) = username {
crate::database::models::DBUser {
id: user_id,
github_id: if provider == AuthProvider::GitHub {
Some(
self.id.clone().parse().map_err(|_| {
AuthenticationError::InvalidCredentials
})?,
)
} else {
None
},
discord_id: if provider == AuthProvider::Discord {
Some(
self.id.parse().map_err(|_| {
AuthenticationError::InvalidCredentials
})?,
)
} else {
None
},
gitlab_id: if provider == AuthProvider::GitLab {
Some(
self.id.parse().map_err(|_| {
AuthenticationError::InvalidCredentials
})?,
)
} else {
None
},
google_id: if provider == AuthProvider::Google {
Some(self.id.clone())
} else {
None
},
steam_id: if provider == AuthProvider::Steam {
Some(
self.id.parse().map_err(|_| {
AuthenticationError::InvalidCredentials
})?,
)
} else {
None
},
microsoft_id: if provider == AuthProvider::Microsoft {
Some(self.id.clone())
} else {
None
},
password: None,
paypal_id: if provider == AuthProvider::PayPal {
Some(self.id)
} else {
None
},
paypal_country: self.country,
paypal_email: if provider == AuthProvider::PayPal {
self.email.clone()
} else {
None
},
venmo_handle: None,
stripe_customer_id: None,
totp_secret: None,
username,
email: self.email.clone(),
email_verified: self.email.is_some(),
avatar_url,
raw_avatar_url,
bio: self.bio,
created: Utc::now(),
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
is_subscribed_to_newsletter: false,
}
.insert(transaction)
.await?;
Ok(user_id)
} else {
Err(AuthenticationError::InvalidCredentials)
DBUser {
id: user_id,
github_id: if provider == AuthProvider::GitHub {
Some(
self.id
.clone()
.parse()
.map_err(|_| AuthenticationError::InvalidCredentials)?,
)
} else {
None
},
discord_id: if provider == AuthProvider::Discord {
Some(
self.id
.parse()
.map_err(|_| AuthenticationError::InvalidCredentials)?,
)
} else {
None
},
gitlab_id: if provider == AuthProvider::GitLab {
Some(
self.id
.parse()
.map_err(|_| AuthenticationError::InvalidCredentials)?,
)
} else {
None
},
google_id: if provider == AuthProvider::Google {
Some(self.id.clone())
} else {
None
},
steam_id: if provider == AuthProvider::Steam {
Some(
self.id
.parse()
.map_err(|_| AuthenticationError::InvalidCredentials)?,
)
} else {
None
},
microsoft_id: if provider == AuthProvider::Microsoft {
Some(self.id.clone())
} else {
None
},
password: None,
paypal_id: if provider == AuthProvider::PayPal {
Some(self.id)
} else {
None
},
paypal_country: self.country,
paypal_email: if provider == AuthProvider::PayPal {
self.email.clone()
} else {
None
},
venmo_handle: None,
stripe_customer_id: None,
totp_secret: None,
username,
email: self.email.clone(),
email_verified: self.email.is_some(),
avatar_url,
raw_avatar_url,
bio: self.bio,
created: Utc::now(),
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
is_subscribed_to_newsletter: sign_up_newsletter,
}
.insert(transaction)
.await?;
Ok(user_id)
}
}
@@ -1043,7 +1029,7 @@ impl AuthProvider {
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct AuthorizationInit {
pub url: String,
pub url: Url,
#[serde(default)]
pub provider: AuthProvider,
pub token: Option<String>,
@@ -1103,9 +1089,7 @@ pub async fn init(
"Starting authentication flow"
);
let url =
url::Url::parse(&info.url).map_err(|_| AuthenticationError::Url)?;
let url = info.url;
let domain = url.host_str().ok_or(AuthenticationError::Url)?;
if !ENV
.ALLOWED_CALLBACK_URLS
@@ -1135,7 +1119,7 @@ pub async fn init(
let state = DBFlow::OAuth {
user_id,
url: info.url,
url,
provider: info.provider,
existing_user_id,
}
@@ -1161,9 +1145,54 @@ pub async fn auth_callback(
req: HttpRequest,
Query(query): Query<HashMap<String, String>>,
client: Data<PgPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
redis: Data<RedisPool>,
) -> Result<HttpResponse, crate::auth::templates::ErrorPage> {
/// Ensures that the OAuth flow is removed from Redis when dropped.
///
/// A guard is used here since it's safer than manually removing the flow
/// in each branch.
struct FlowGuard {
state: Option<String>,
redis: Data<RedisPool>,
}
impl Drop for FlowGuard {
fn drop(&mut self) {
let Some(state) = self.state.clone() else {
// has been replaced
return;
};
let redis = self.redis.clone();
tokio::spawn(async move {
if let Err(err) = DBFlow::remove(&state, &redis).await {
error!("failed to remove DB flow state: {err:#}");
}
});
}
}
impl FlowGuard {
/// Prevents this guard from removing `state` when dropped, instead
/// replacing the flow for `state` with the new given `flow`.
pub async fn replace_with(
mut self,
flow: DBFlow,
) -> Result<(), ApiError> {
let state = self
.state
.clone()
.expect("`self` should not be dropped yet");
let redis = self.redis.clone();
self.state = None;
flow.insert_with_state(Duration::minutes(10), &redis, &state)
.await
.wrap_internal_err("failed to insert new flow state")?;
Ok(())
}
}
let state_string = query
.get("state")
.ok_or_else(|| AuthenticationError::InvalidCredentials)?
@@ -1189,9 +1218,10 @@ pub async fn auth_callback(
)));
};
DBFlow::remove(&state, &redis)
.await
.wrap_err("failed to remove flow")?;
let flow_guard = FlowGuard {
state: Some(state.clone()),
redis: redis.clone(),
};
let token = provider
.get_token(query)
@@ -1250,13 +1280,13 @@ pub async fn auth_callback(
.wrap_err("failed to clear user caches")?;
return Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*url))
.append_header(("Location", url.as_str()))
.json(serde_json::json!({ "url": url })));
}
if let Some(id) = user_id {
if user_id_opt.is_some() {
return Err(AuthenticationError::DuplicateUser);
return Err(AuthenticationError::ProviderAlreadyLinked);
}
provider
@@ -1285,65 +1315,74 @@ pub async fn auth_callback(
.await?;
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*url))
.append_header(("Location", url.as_str()))
.json(serde_json::json!({ "url": url })))
} else {
let user_id = if let Some(user_id) = user_id_opt {
let user = crate::database::models::DBUser::get_id(
user_id, &**client, &redis,
)
.await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
} else if let Some(user_id) = user_id_opt {
let user = crate::database::models::DBUser::get_id(
user_id, &**client, &redis,
)
.await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
if user.totp_secret.is_some() {
let flow = DBFlow::Login2FA { user_id: user.id }
.insert(Duration::minutes(30), &redis)
.await?;
let redirect_url = format!(
"{}{}error=2fa_required&flow={}",
url,
if url.contains('?') { "&" } else { "?" },
flow
);
return Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*redirect_url))
.json(serde_json::json!({ "url": redirect_url })));
}
user_id
} else {
oauth_user
.create_account(
provider,
&mut transaction,
&client,
&file_host,
&redis,
)
.await?
};
let session =
issue_session(req, user_id, &mut transaction, &redis, None)
if user.totp_secret.is_some() {
let flow = DBFlow::Login2FA { user_id: user.id }
.insert(Duration::minutes(30), &redis)
.await?;
transaction.commit().await?;
let redirect_url = format!(
"{}{}code={}{}",
url,
if url.contains('?') { '&' } else { '?' },
session.session,
if user_id_opt.is_none() {
"&new_account=true"
} else {
""
}
);
let mut redirect_url = url.clone();
redirect_url
.query_pairs_mut()
.append_pair("error", "2fa_required")
.append_pair("flow", &flow);
Ok(HttpResponse::TemporaryRedirect()
.append_header((LOCATION, redirect_url.as_str()))
.json(serde_json::json!({ "url": redirect_url })))
} else {
let session =
issue_session(req, user_id, &mut transaction, &redis, None)
.await?;
transaction.commit().await?;
let mut redirect_url = url.clone();
redirect_url
.query_pairs_mut()
.append_pair("code", &session.session);
Ok(HttpResponse::TemporaryRedirect()
.append_header((LOCATION, redirect_url.as_str()))
.json(serde_json::json!({ "url": redirect_url })))
}
} else {
// user doesn't already exist; the user wants to create a new Modrinth account
// linked to their OAuth account.
// for this, we redirect them to a frontend page which lets them set a username.
// then frontend will call `/create/oauth` with the same state parameter and
// chosen settings (username, subscribe to newsletter), and handle navigation.
let suggested_username = oauth_user.username.clone();
flow_guard
.replace_with(DBFlow::OAuthPending {
url: url.clone(),
provider,
user: oauth_user,
})
.await
.wrap_err("failed to replace flow for state")?;
let mut redirect_url = url.clone();
redirect_url
.query_pairs_mut()
.append_pair("state", &state)
.append_pair(
"requires_dob",
&requires_dob(provider).to_string(),
)
.append_pair("username", &suggested_username);
let redirect_url = redirect_url.to_string();
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*redirect_url))
.append_header((LOCATION, &*redirect_url))
.json(serde_json::json!({ "url": redirect_url })))
}
}
@@ -1352,6 +1391,85 @@ pub async fn auth_callback(
Ok(res?)
}
fn requires_dob(provider: AuthProvider) -> bool {
matches!(
provider,
AuthProvider::GitHub | AuthProvider::GitLab | AuthProvider::Steam
)
}
#[derive(Deserialize, Validate, utoipa::ToSchema)]
struct NewOAuthAccount {
// keep in sync with NewAccount
#[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))]
pub username: String,
pub state: String,
pub challenge: String,
pub sign_up_newsletter: bool,
}
#[utoipa::path(
post,
operation_id = "createOAuthAccount",
responses(
(status = 200, description = "OAuth account created"),
(status = 400, description = "Invalid input")
)
)]
#[post("/create/oauth")]
async fn create_oauth_account(
req: HttpRequest,
db: Data<PgPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
redis: Data<RedisPool>,
web::Json(new_account): web::Json<NewOAuthAccount>,
) -> Result<HttpResponse, ApiError> {
new_account.validate().map_err(|err| {
ApiError::InvalidInput(validation_errors_to_string(err, None))
})?;
if !check_hcaptcha(&req, &new_account.challenge).await? {
return Err(ApiError::Turnstile);
}
let flow = DBFlow::get(&new_account.state, &redis)
.await
.wrap_internal_err("failed to fetch flow state")?
.wrap_request_err("no flow for state")?;
let DBFlow::OAuthPending {
url: _url,
provider,
user,
} = flow
else {
return Err(ApiError::Internal(eyre!("invalid flow kind")));
};
let mut txn = db
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
let user_id = user
.create_account(
provider,
&mut txn,
&db,
&file_host,
&redis,
new_account.username,
new_account.sign_up_newsletter,
)
.await?;
let session = issue_session(req, user_id, &mut txn, &redis, None).await?;
let res = crate::models::sessions::Session::from(session, true, None);
txn.commit().await?;
Ok(HttpResponse::Ok().json(res))
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct DeleteAuthProvider {
pub provider: AuthProvider,
@@ -1453,16 +1571,273 @@ pub async fn check_sendy_subscription(
#[derive(Deserialize, Validate, utoipa::ToSchema)]
pub struct NewAccount {
#[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))]
// keep in sync with NewOAuthAccount
pub username: String,
#[validate(length(min = 8, max = 256))]
pub password: String,
#[validate(email)]
pub email: String,
pub challenge: String,
pub challenge: Option<String>,
pub sign_up_newsletter: Option<bool>,
}
#[derive(Debug, Validate)]
struct AccountRegisterFlow {
#[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))]
username: String,
#[validate(length(min = 8, max = 256))]
password: String,
#[validate(email)]
email: String,
sign_up_newsletter: bool,
}
#[derive(Debug)]
struct ReadyAccountRegisterFlow {
inner: AccountRegisterFlow,
}
#[derive(Debug, Error)]
enum AccountRegisterValidateError {
#[error("Username is already taken on Modrinth.")]
UsernameTaken,
#[error(
"Email is already registered on Modrinth. Try 'Forgot password' to access your account."
)]
DuplicateEmail,
#[error("{}", match .0 {
Some(feedback) => format!("Password too weak: {feedback}"),
None => "Specified password is too weak! Please improve its strength.".to_string(),
})]
WeakPassword(Option<String>),
#[error("{0}")]
InvalidInput(String),
}
impl AccountRegisterValidateError {
fn error_code(&self) -> &'static str {
match self {
AccountRegisterValidateError::UsernameTaken => "username_taken",
AccountRegisterValidateError::DuplicateEmail => "duplicate_email",
AccountRegisterValidateError::WeakPassword(_) => "weak_password",
AccountRegisterValidateError::InvalidInput(_) => "invalid_input",
}
}
}
impl actix_web::ResponseError for AccountRegisterValidateError {
fn status_code(&self) -> StatusCode {
StatusCode::BAD_REQUEST
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).json(ApiErrorResponse {
error: self.error_code(),
description: self.to_string(),
details: None,
})
}
}
impl From<AccountRegisterValidateError> for ApiError {
fn from(value: AccountRegisterValidateError) -> Self {
match &value {
AccountRegisterValidateError::UsernameTaken => {
ApiError::Authentication(AuthenticationError::UsernameTaken)
}
AccountRegisterValidateError::DuplicateEmail => {
ApiError::Authentication(AuthenticationError::DuplicateEmail)
}
_ => ApiError::InvalidInput(value.to_string()),
}
}
}
impl From<NewAccount> for AccountRegisterFlow {
fn from(account: NewAccount) -> Self {
Self {
username: account.username,
password: account.password,
email: account.email,
sign_up_newsletter: account.sign_up_newsletter.unwrap_or(false),
}
}
}
impl AccountRegisterFlow {
async fn validate(
self,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<ReadyAccountRegisterFlow, AccountRegisterValidateError> {
validator::Validate::validate(&self).map_err(|err| {
AccountRegisterValidateError::InvalidInput(
validation_errors_to_string(err, None),
)
})?;
if crate::database::models::DBUser::get(
&self.username,
&mut *transaction,
redis,
)
.await
.map_err(|err| {
AccountRegisterValidateError::InvalidInput(err.to_string())
})?
.is_some()
{
return Err(AccountRegisterValidateError::UsernameTaken);
}
let score =
zxcvbn::zxcvbn(&self.password, &[&self.username, &self.email]);
if score.score() < Score::Three {
let feedback = score
.feedback()
.and_then(|x| x.warning())
.map(|w| w.to_string());
return Err(AccountRegisterValidateError::WeakPassword(feedback));
}
if !crate::database::models::DBUser::get_by_case_insensitive_email(
&self.email,
&mut *transaction,
)
.await
.map_err(|err| {
AccountRegisterValidateError::InvalidInput(err.to_string())
})?
.is_empty()
{
return Err(AccountRegisterValidateError::DuplicateEmail);
}
Ok(ReadyAccountRegisterFlow { inner: self })
}
}
impl ReadyAccountRegisterFlow {
async fn execute(
self,
req: HttpRequest,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
email_queue: &EmailQueue,
) -> Result<crate::models::sessions::Session, ApiError> {
let register_flow = self.inner;
let user_id =
crate::database::models::generate_user_id(transaction).await?;
let hasher = Argon2::default();
let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy());
let password_hash = hasher
.hash_password(register_flow.password.as_bytes(), &salt)?
.to_string();
crate::database::models::DBUser {
id: user_id,
github_id: None,
discord_id: None,
gitlab_id: None,
google_id: None,
steam_id: None,
microsoft_id: None,
password: Some(password_hash),
paypal_id: None,
paypal_country: None,
paypal_email: None,
venmo_handle: None,
stripe_customer_id: None,
totp_secret: None,
username: register_flow.username.clone(),
email: Some(register_flow.email.clone()),
email_verified: false,
avatar_url: None,
raw_avatar_url: None,
bio: None,
created: Utc::now(),
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
is_subscribed_to_newsletter: register_flow.sign_up_newsletter,
}
.insert(transaction)
.await
.map_err(|err| {
if let sqlx::Error::Database(database_error) = &err {
match database_error.constraint() {
Some("username_unique" | "users_username_key") => {
return ApiError::from(
AccountRegisterValidateError::UsernameTaken,
);
}
Some("email_unique" | "users_email_key") => {
return ApiError::from(
AccountRegisterValidateError::DuplicateEmail,
);
}
_ => {}
}
}
ApiError::from(err)
})?;
let session =
issue_session(req, user_id, transaction, redis, None).await?;
let res = crate::models::sessions::Session::from(session, true, None);
let mailbox: Mailbox = register_flow.email.parse().map_err(|_| {
ApiError::InvalidInput("Invalid email address!".to_string())
})?;
let flow = DBFlow::ConfirmEmail {
user_id,
confirm_email: register_flow.email.clone(),
}
.insert(Duration::hours(24), redis)
.await?;
email_queue
.send_one(
transaction,
NotificationBody::VerifyEmail { flow },
user_id,
mailbox,
)
.await?
.as_user_error()?;
Ok(res)
}
}
#[utoipa::path(
post,
operation_id = "validateCreateAccountWithPassword",
responses(
(status = 200, description = "Account input is valid"),
(status = 400, description = "Invalid input")
)
)]
#[post("/create/validate")]
pub async fn validate_create_account_with_password(
pool: Data<PgPool>,
redis: Data<RedisPool>,
new_account: web::Json<NewAccount>,
) -> Result<(), AccountRegisterValidateError> {
let mut transaction = pool.begin().await.map_err(|err| {
AccountRegisterValidateError::InvalidInput(err.to_string())
})?;
AccountRegisterFlow::from(new_account.into_inner())
.validate(&mut transaction, &redis)
.await?;
Ok(())
}
#[utoipa::path(
post,
operation_id = "createAccountPassword",
@@ -1479,122 +1854,23 @@ pub async fn create_account_with_password(
new_account: web::Json<NewAccount>,
email: web::Data<EmailQueue>,
) -> Result<HttpResponse, ApiError> {
new_account.0.validate().map_err(|err| {
ApiError::InvalidInput(validation_errors_to_string(err, None))
})?;
let new_account = new_account.into_inner();
if !check_hcaptcha(&req, &new_account.challenge).await? {
if !check_hcaptcha(&req, new_account.challenge.as_deref().unwrap_or(""))
.await?
{
return Err(ApiError::Turnstile);
}
if crate::database::models::DBUser::get(
&new_account.username,
&**pool,
&redis,
)
.await?
.is_some()
{
return Err(ApiError::InvalidInput("Username is taken!".to_string()));
}
let mut transaction = pool.begin().await?;
let user_id =
crate::database::models::generate_user_id(&mut transaction).await?;
let new_account = new_account.0;
let score = zxcvbn::zxcvbn(
&new_account.password,
&[&new_account.username, &new_account.email],
);
if score.score() < Score::Three {
return Err(ApiError::InvalidInput(
if let Some(feedback) = score.feedback().and_then(|x| x.warning()) {
format!("Password too weak: {feedback}")
} else {
"Specified password is too weak! Please improve its strength."
.to_string()
},
));
}
let hasher = Argon2::default();
let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy());
let password_hash = hasher
.hash_password(new_account.password.as_bytes(), &salt)?
.to_string();
if !crate::database::models::DBUser::get_by_case_insensitive_email(
&new_account.email,
&**pool,
)
.await?
.is_empty()
{
return Err(ApiError::InvalidInput(
"Email is already registered on Modrinth! Try 'Forgot password' to access your account.".to_string(),
));
}
crate::database::models::DBUser {
id: user_id,
github_id: None,
discord_id: None,
gitlab_id: None,
google_id: None,
steam_id: None,
microsoft_id: None,
password: Some(password_hash),
paypal_id: None,
paypal_country: None,
paypal_email: None,
venmo_handle: None,
stripe_customer_id: None,
totp_secret: None,
username: new_account.username.clone(),
email: Some(new_account.email.clone()),
email_verified: false,
avatar_url: None,
raw_avatar_url: None,
bio: None,
created: Utc::now(),
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
is_subscribed_to_newsletter: new_account
.sign_up_newsletter
.unwrap_or(false),
}
.insert(&mut transaction)
.await?;
let session =
issue_session(req, user_id, &mut transaction, &redis, None).await?;
let res = crate::models::sessions::Session::from(session, true, None);
let mailbox: Mailbox = new_account.email.parse().map_err(|_| {
ApiError::InvalidInput("Invalid email address!".to_string())
})?;
let flow = DBFlow::ConfirmEmail {
user_id,
confirm_email: new_account.email.clone(),
}
.insert(Duration::hours(24), &redis)
.await?;
email
.send_one(
&mut transaction,
NotificationBody::VerifyEmail { flow },
user_id,
mailbox,
)
.await?
.as_user_error()?;
let ready_flow = AccountRegisterFlow::from(new_account)
.validate(&mut transaction, &redis)
.await?;
let res = ready_flow
.execute(req, &mut transaction, &redis, &email)
.await?;
transaction.commit().await?;
Ok(HttpResponse::Ok().json(res))
@@ -57,6 +57,39 @@ export class LabrinthAuthV2Module extends AbstractModule {
})
}
/**
* Validate email/password inputs for account creation without creating an account.
*
* @param data - Prospective account credentials
*/
public async validateCreateAccount(
data: Labrinth.Auth.v2.ValidateCreateAccountRequest,
): Promise<void> {
return this.client.request(`/auth/create/validate`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Create a new account from an OAuth callback flow state
*
* @param data - OAuth account creation data
* @returns Promise resolving to a session response
*/
public async createOAuthAccount(
data: Labrinth.Auth.v2.CreateOAuthAccountRequest,
): Promise<Labrinth.Auth.v2.CreateOAuthAccountResponse> {
return this.client.request<Labrinth.Auth.v2.CreateOAuthAccountResponse>(`/auth/create/oauth`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Begin a password reset flow by sending a recovery email
*
@@ -278,6 +278,23 @@ export namespace Labrinth {
session: string
}
export type ValidateCreateAccountRequest = {
username: string
password: string
email: string
}
export type CreateOAuthAccountRequest = {
username: string
state: string
challenge: string
sign_up_newsletter: boolean
}
export type CreateOAuthAccountResponse = {
session: string
}
export type ResetPasswordRequest = {
username: string
challenge: string
+1 -1
View File
@@ -13,7 +13,7 @@
@click="toggle"
>
<span
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid"
class="w-5 h-5 aspect-square rounded-md flex items-center justify-center border-[1px] border-solid"
:class="
(modelValue
? 'bg-brand border-button-border text-brand-inverted'