Improve mobile auth & added Turnstile captcha to registration flow

This change adds a Cloudflare Turnstile captcha verification step to the
user registration process, helping prevent automated account creation.
This commit is contained in:
srgooglo 2025-07-07 17:25:35 +02:00
parent 44c2500491
commit abd65cf6ff
12 changed files with 393 additions and 210 deletions

View File

@ -68,6 +68,7 @@
"react-player": "^2.16.0",
"react-rnd": "^10.4.14",
"react-transition-group": "^4.4.5",
"react-turnstile": "^1.1.4",
"react-useanimations": "^2.10.0",
"remark-gfm": "^3.0.1",
"rxjs": "^7.5.5",

View File

@ -12,8 +12,9 @@ import UsernameStep from "./steps/username"
import PasswordStep from "./steps/password"
import EmailStep from "./steps/email"
import TOSStep from "./steps/tos"
import CaptchaStep from "./steps/captcha"
const steps = [UsernameStep, PasswordStep, EmailStep, TOSStep]
const steps = [UsernameStep, PasswordStep, EmailStep, TOSStep, CaptchaStep]
const RegisterForm = (props) => {
const [finishing, setFinishing] = React.useState(false)
@ -35,6 +36,7 @@ const RegisterForm = (props) => {
password: stepsValues.password,
email: stepsValues.email,
tos: stepsValues.tos,
captcha: stepsValues.captcha,
}).catch((err) => {
setFinishSuccess(false)
setFinishing(false)
@ -107,8 +109,8 @@ const RegisterForm = (props) => {
<>
<h1>👋 Hi! Nice to meet you</h1>
<p>
Tell us some basic information to get started
creating your account.
Tell us some basic information to get started creating your
account.
</p>
</>
)}
@ -116,8 +118,7 @@ const RegisterForm = (props) => {
{!finishSuccess && !finishing && step > 0 && (
<>
<h1>
{currentStepData?.icon &&
createIconRender(currentStepData.icon)}
{currentStepData?.icon && createIconRender(currentStepData.icon)}
{currentStepData?.title}
</h1>
@ -150,10 +151,7 @@ const RegisterForm = (props) => {
<div className="register_form_success">
<Icons.CheckCircleOutlined />
<h1>Welcome abord!</h1>
<p>
One last step, we need you to login with your new
account.
</p>
<p>One last step, we need you to login with your new account.</p>
<antd.Button
type="primary"
@ -165,22 +163,21 @@ const RegisterForm = (props) => {
)}
{finishError && (
<antd.Alert type="error" message={finishError.message} />
<antd.Alert
type="error"
message={finishError.message}
/>
)}
{!finishSuccess && !finishing && (
<div className="register_form_actions">
{step === 0 && (
<antd.Button
onClick={() => props.setActiveKey("selector")}
>
<antd.Button onClick={() => props.setActiveKey("selector")}>
Cancel
</antd.Button>
)}
{step > 0 && (
<antd.Button onClick={() => prevStep()}>
Back
</antd.Button>
<antd.Button onClick={() => prevStep()}>Back</antd.Button>
)}
<antd.Button

View File

@ -0,0 +1,22 @@
import Turnstile from "react-turnstile"
const CaptchaStepComponent = (props) => {
return (
<Turnstile
sitekey={import.meta.env.VITE_TURNSTILE_SITEKEY}
onVerify={(token) => {
props.updateValue(token)
}}
/>
)
}
export default {
key: "captcha",
title: "Step 4",
icon: "FiLock",
description:
"We need you to prove that you are a human. Please enter the captcha below.",
required: true,
content: CaptchaStepComponent,
}

View File

@ -4,87 +4,103 @@ import * as antd from "antd"
import AuthModel from "@models/auth"
const EmailStepComponent = (props) => {
const [email, setEmail] = React.useState(props.currentValue ?? "")
const [email, setEmail] = React.useState(props.currentValue ?? "")
const [loading, setLoading] = React.useState(false)
const [validFormat, setValidFormat] = React.useState(null)
const [emailAvailable, setEmailAvailable] = React.useState(null)
const [loading, setLoading] = React.useState(false)
const [validFormat, setValidFormat] = React.useState(null)
const [emailAvailable, setEmailAvailable] = React.useState(null)
const isValid = () => {
return email.length > 0 && validFormat && emailAvailable
}
const isValid = () => {
return email.length > 0 && validFormat && emailAvailable
}
const checkIfIsEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
const checkIfIsEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
const submit = () => {
if (!isValid()) return
const submit = () => {
if (!isValid()) {
return false
}
props.onPressEnter()
}
props.onPressEnter()
}
const handleUpdate = (e) => {
setEmail(e.target.value)
}
const handleUpdate = (e) => {
setEmail(e.target.value)
}
React.useEffect(() => {
if (email.length === 0) {
setEmailAvailable(null)
setValidFormat(null)
React.useEffect(() => {
if (email.length === 0) {
setEmailAvailable(null)
setValidFormat(null)
return
}
return
}
props.updateValue(null)
props.updateValue(null)
setLoading(true)
setLoading(true)
setValidFormat(checkIfIsEmail(email))
const isEmailValid = checkIfIsEmail(email)
setValidFormat(isEmailValid)
// check if email is available
const timer = setTimeout(async () => {
if (!validFormat) return
// check if email is available
const timer = setTimeout(async () => {
if (!isEmailValid) {
return false
}
const request = await AuthModel.availability({ email }).catch((error) => {
antd.message.error(`Cannot check email availability: ${error.message}`)
const request = await AuthModel.availability({ email }).catch((error) => {
antd.message.error(`Cannot check email availability: ${error.message}`)
return false
})
return false
})
if (request) {
setEmailAvailable(!request.exist)
if (request) {
setEmailAvailable(!request.exist)
if (request.exist) {
antd.message.error("Email is already in use")
props.updateValue(null)
} else {
props.updateValue(email)
}
}
if (request.exist) {
antd.message.error("Email is already in use")
props.updateValue(null)
} else {
props.updateValue(email)
}
}
setLoading(false)
}, 1000)
setLoading(false)
}, 1000)
return () => clearTimeout(timer)
}, [email])
return () => clearTimeout(timer)
}, [email])
return <div className="register_form_step_content">
<antd.Input
defaultValue={props.currentValue}
placeholder="Email"
onPressEnter={submit}
onChange={handleUpdate}
status={email.length == 0 ? "default" : loading ? "default" : (isValid() ? "success" : "error")}
/>
</div>
return (
<div className="register_form_step_content">
<antd.Input
defaultValue={props.currentValue}
placeholder="Email"
onPressEnter={submit}
onChange={handleUpdate}
status={
email.length == 0
? "default"
: loading
? "default"
: isValid()
? "success"
: "error"
}
/>
</div>
)
}
export default {
key: "email",
title: "Step 3",
icon: "FiMail",
description: "Enter a email for the account, it can be used to access to your account. \n Will not be shared with anyone else and not be used for marketing purposes.",
required: true,
content: EmailStepComponent,
key: "email",
title: "Step 3",
icon: "FiMail",
description:
"Enter a email for the account, it can be used to access to your account. \n Will not be shared with anyone else and not be used for marketing purposes.",
required: true,
content: EmailStepComponent,
}

View File

@ -1,80 +1,81 @@
import React from "react"
import * as antd from "antd"
import MarkdownReader from "@components/MarkdownReader"
import config from "@config"
const FrameStyle = {
"width": "60vw",
"max-width": "60vw",
"height": "90vh",
"max-height": "90vh",
"overflow": "overlay",
"justify-content": "flex-start",
width: "60vw",
"max-width": "60vw",
height: "90vh",
"max-height": "90vh",
overflow: "overlay",
"justify-content": "flex-start",
}
const LegalDocumentsDecorators = {
"terms": "Terms of Service",
"privacy": "Privacy Policy",
terms: "Terms of Service",
privacy: "Privacy Policy",
}
function composeConfirmationCheckboxLabel(documents) {
let labels = [
"I have read and accept"
]
let labels = ["I have read and accept"]
documents.forEach(([key, value], index) => {
const isLast = index === documents.length - 1
documents.forEach(([key, value], index) => {
const isLast = index === documents.length - 1
labels.push(`the ${LegalDocumentsDecorators[key] ?? `document (${key})`} ${!isLast ? "and" : ""}`)
})
labels.push(
`the ${LegalDocumentsDecorators[key] ?? `document (${key})`} ${!isLast ? "and" : ""}`,
)
})
return labels.join(" ")
return labels.join(" ")
}
const TermsOfServiceStepComponent = (props) => {
const legalDocuments = Object.entries(config.legal)
const legalDocuments = Object.entries(config.legal)
return <div className="register_form_step_content">
{
Object.entries(config.legal).map(([key, value]) => {
if (!value) {
return null
}
return (
<div className="register_form_step_content">
{Object.entries(config.legal).map(([key, value]) => {
if (!value) {
return null
}
return <antd.Button
key={key}
onClick={() => {
app.layout.modal.open(key, MarkdownReader, {
includeCloseButton: true,
frameContentStyle: FrameStyle,
props: {
url: value
}
})
}}
>
Read {LegalDocumentsDecorators[key] ?? `document (${key})`}
</antd.Button>
})
}
return (
<antd.Button
key={key}
onClick={() => {
app.layout.modal.open(key, MarkdownReader, {
includeCloseButton: true,
frameContentStyle: FrameStyle,
props: {
url: value,
},
})
}}
>
Read {LegalDocumentsDecorators[key] ?? `document (${key})`}
</antd.Button>
)
})}
<antd.Checkbox
defaultChecked={props.currentValue}
onChange={(event) => {
props.updateValue(event.target.checked)
}}
>
{composeConfirmationCheckboxLabel(legalDocuments)}
</antd.Checkbox>
</div>
<antd.Checkbox
defaultChecked={props.currentValue}
onChange={(event) => {
props.updateValue(event.target.checked)
}}
>
{composeConfirmationCheckboxLabel(legalDocuments)}
</antd.Checkbox>
</div>
)
}
export default {
key: "tos",
title: "Step 3",
icon: "FileDone",
description: "Take your time to read these legal documents.",
required: true,
content: TermsOfServiceStepComponent,
key: "tos",
title: "Step 3",
icon: "FileDone",
description: "Take your time to read these legal documents.",
required: true,
content: TermsOfServiceStepComponent,
}

View File

@ -6,9 +6,14 @@ import { Icons } from "@components/Icons"
const MainSelector = (props) => {
return (
<>
<div className="content_header">
<img src={config.logo.alt} className="logo" />
</div>
{!app.isMobile && (
<div className="content_header">
<img
src={config.logo.alt}
className="logo"
/>
</div>
)}
<div className="actions">
{app.userData && (

View File

@ -1,32 +1,55 @@
import React from "react"
import config from "@config"
import useRandomFeaturedWallpaperUrl from "@hooks/useRandomFeaturedWallpaperUrl"
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
import RegisterForm from "./forms/register"
import MainSelector from "./forms/selector"
import RecoveryForm from "./forms/recovery"
const keyToComponents = {
selector: MainSelector,
register: RegisterForm,
recovery: RecoveryForm,
}
import "./index.mobile.less"
export default (props) => {
const AuthPage = (props) => {
const randomWallpaperURL = useRandomFeaturedWallpaperUrl()
React.useEffect(() => {
if (app.userData) {
app.navigation.goMain()
} else {
app.auth.login()
}
}, [])
const [activeKey, setActiveKey] = useUrlQueryActiveKey({
defaultKey: "selector",
})
return (
<div className="loginPage">
<div className="login-page">
<div
style={{
backgroundImage: `url(${randomWallpaperURL})`,
}}
className="wallpaper"
>
{/* <p>
{wallpaperData?.author ? wallpaperData.author : null}
</p> */}
/>
<div className="login-page-card">
<div className="login-page-card__header">
<img
className="login-page-card__header__logo"
src={config.logo.alt}
/>
</div>
<div className="login-page-card__content">
{React.createElement(
keyToComponents[activeKey] ?? keyToComponents["selector"],
{
setActiveKey: setActiveKey,
},
)}
</div>
</div>
</div>
)
}
export default AuthPage

View File

@ -1,22 +1,89 @@
.loginPage {
position: relative;
.login-page {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
.wallpaper {
position: absolute;
align-items: center;
justify-content: center;
top: 0;
left: 0;
width: 100%;
height: 100vh;
height: 100dvh;
width: 100%;
height: 100vh;
height: 100dvh;
.wallpaper {
position: fixed;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100vh;
height: 100dvh;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
}
.login-page-card {
position: relative;
display: flex;
flex-direction: column;
width: 95%;
padding: 15px;
background-color: var(--background-color-primary);
border-radius: 24px;
gap: 30px;
&__header {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
&__logo {
width: 40px;
height: 40px;
}
}
&__content {
display: flex;
flex-direction: column;
.ant-btn {
height: fit-content;
padding: 7px 15px;
font-size: 0.8rem;
}
.actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
}
.register_form {
position: unset;
.register_form_actions {
top: 0;
bottom: unset;
}
}
}
}
}

View File

@ -104,6 +104,8 @@ const LyricsText = React.forwardRef((props, textRef) => {
React.useEffect(() => {
setVisible(false)
setCurrentLineIndex(0)
// set scroll top to 0
textRef.current.scrollTop = 0
}, [playerState.track_manifest])
React.useEffect(() => {

View File

@ -35,7 +35,6 @@ export default class API extends Server {
onExit() {
this.queuesManager.cleanUp()
console.log("Jijija")
}
}

View File

@ -1,60 +1,84 @@
import bcrypt from "bcrypt"
import { User } from "@db_models"
import requiredFields from "@shared-utils/requiredFields"
import Account from "@classes/account"
import requiredFields from "@shared-utils/requiredFields"
import verifyTurnstileToken from "@utils/verifyTurnstileToken"
export default async (payload) => {
requiredFields(["username", "password", "email"], payload)
requiredFields(["username", "password", "email"], payload)
let { username, password, email, public_name, roles, avatar, accept_tos } = payload
let {
username,
password,
email,
public_name,
roles,
avatar,
accept_tos,
captcha,
} = payload
if (ToBoolean(accept_tos) !== true) {
throw new OperationError(400, "You must accept the terms of service in order to create an account.")
}
if (ToBoolean(accept_tos) !== true) {
throw new OperationError(
400,
"You must accept the terms of service in order to create an account.",
)
}
await Account.usernameMeetPolicy(username)
if (!captcha) {
throw new OperationError(400, "Captcha token is required")
}
// check if username is already taken
const existentUser = await User
.findOne({ username: username })
const turnstileResponse = await verifyTurnstileToken(captcha)
if (existentUser) {
throw new OperationError(400, "User already exists")
}
if (turnstileResponse.success !== true) {
throw new OperationError(400, "Invalid captcha token")
}
// check if the email is already in use
const existentEmail = await User
.findOne({ email: email })
.select("+email")
await Account.usernameMeetPolicy(username)
if (existentEmail) {
throw new OperationError(400, "Email already in use")
}
// check if username is already taken
const existentUser = await User.findOne({ username: username })
await Account.passwordMeetPolicy(password)
if (existentUser) {
throw new OperationError(400, "User already exists")
}
// hash the password
const hash = bcrypt.hashSync(password, parseInt(process.env.BCRYPT_ROUNDS ?? 3))
// check if the email is already in use
const existentEmail = await User.findOne({ email: email }).select("+email")
let user = new User({
username: username,
password: hash,
email: email,
public_name: public_name,
avatar: avatar ?? `https://api.dicebear.com/7.x/thumbs/svg?seed=${username}`,
roles: roles,
created_at: new Date().getTime(),
accept_tos: accept_tos,
activated: false,
})
if (existentEmail) {
throw new OperationError(400, "Email already in use")
}
await user.save()
await Account.passwordMeetPolicy(password)
await Account.sendActivationCode(user._id.toString())
// hash the password
const hash = bcrypt.hashSync(
password,
parseInt(process.env.BCRYPT_ROUNDS ?? 3),
)
return {
activation_required: true,
user: user,
}
let user = new User({
username: username,
password: hash,
email: email,
public_name: public_name,
avatar:
avatar ?? `https://api.dicebear.com/7.x/thumbs/svg?seed=${username}`,
roles: roles,
created_at: new Date().getTime(),
accept_tos: accept_tos,
activated: false,
})
await user.save()
await Account.sendActivationCode(user._id.toString())
return {
activation_required: true,
user: user,
}
}

View File

@ -0,0 +1,26 @@
import axios from "axios"
export default async (token) => {
const secret = process.env.TURNSTILE_SECRET
if (!secret) {
throw new Error("Turnstile secret is not set")
}
let response = await axios({
url: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: {
secret: secret,
response: token,
},
}).catch((err) => {
console.error(err.response.data)
throw new Error("Turnstile verification failed")
})
return response.data
}