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-player": "^2.16.0",
"react-rnd": "^10.4.14", "react-rnd": "^10.4.14",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-turnstile": "^1.1.4",
"react-useanimations": "^2.10.0", "react-useanimations": "^2.10.0",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"rxjs": "^7.5.5", "rxjs": "^7.5.5",

View File

@ -12,8 +12,9 @@ import UsernameStep from "./steps/username"
import PasswordStep from "./steps/password" import PasswordStep from "./steps/password"
import EmailStep from "./steps/email" import EmailStep from "./steps/email"
import TOSStep from "./steps/tos" 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 RegisterForm = (props) => {
const [finishing, setFinishing] = React.useState(false) const [finishing, setFinishing] = React.useState(false)
@ -35,6 +36,7 @@ const RegisterForm = (props) => {
password: stepsValues.password, password: stepsValues.password,
email: stepsValues.email, email: stepsValues.email,
tos: stepsValues.tos, tos: stepsValues.tos,
captcha: stepsValues.captcha,
}).catch((err) => { }).catch((err) => {
setFinishSuccess(false) setFinishSuccess(false)
setFinishing(false) setFinishing(false)
@ -107,8 +109,8 @@ const RegisterForm = (props) => {
<> <>
<h1>👋 Hi! Nice to meet you</h1> <h1>👋 Hi! Nice to meet you</h1>
<p> <p>
Tell us some basic information to get started Tell us some basic information to get started creating your
creating your account. account.
</p> </p>
</> </>
)} )}
@ -116,8 +118,7 @@ const RegisterForm = (props) => {
{!finishSuccess && !finishing && step > 0 && ( {!finishSuccess && !finishing && step > 0 && (
<> <>
<h1> <h1>
{currentStepData?.icon && {currentStepData?.icon && createIconRender(currentStepData.icon)}
createIconRender(currentStepData.icon)}
{currentStepData?.title} {currentStepData?.title}
</h1> </h1>
@ -150,10 +151,7 @@ const RegisterForm = (props) => {
<div className="register_form_success"> <div className="register_form_success">
<Icons.CheckCircleOutlined /> <Icons.CheckCircleOutlined />
<h1>Welcome abord!</h1> <h1>Welcome abord!</h1>
<p> <p>One last step, we need you to login with your new account.</p>
One last step, we need you to login with your new
account.
</p>
<antd.Button <antd.Button
type="primary" type="primary"
@ -165,22 +163,21 @@ const RegisterForm = (props) => {
)} )}
{finishError && ( {finishError && (
<antd.Alert type="error" message={finishError.message} /> <antd.Alert
type="error"
message={finishError.message}
/>
)} )}
{!finishSuccess && !finishing && ( {!finishSuccess && !finishing && (
<div className="register_form_actions"> <div className="register_form_actions">
{step === 0 && ( {step === 0 && (
<antd.Button <antd.Button onClick={() => props.setActiveKey("selector")}>
onClick={() => props.setActiveKey("selector")}
>
Cancel Cancel
</antd.Button> </antd.Button>
)} )}
{step > 0 && ( {step > 0 && (
<antd.Button onClick={() => prevStep()}> <antd.Button onClick={() => prevStep()}>Back</antd.Button>
Back
</antd.Button>
)} )}
<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" import AuthModel from "@models/auth"
const EmailStepComponent = (props) => { const EmailStepComponent = (props) => {
const [email, setEmail] = React.useState(props.currentValue ?? "") const [email, setEmail] = React.useState(props.currentValue ?? "")
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const [validFormat, setValidFormat] = React.useState(null) const [validFormat, setValidFormat] = React.useState(null)
const [emailAvailable, setEmailAvailable] = React.useState(null) const [emailAvailable, setEmailAvailable] = React.useState(null)
const isValid = () => { const isValid = () => {
return email.length > 0 && validFormat && emailAvailable return email.length > 0 && validFormat && emailAvailable
} }
const checkIfIsEmail = (email) => { const checkIfIsEmail = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
} }
const submit = () => { const submit = () => {
if (!isValid()) return if (!isValid()) {
return false
}
props.onPressEnter() props.onPressEnter()
} }
const handleUpdate = (e) => { const handleUpdate = (e) => {
setEmail(e.target.value) setEmail(e.target.value)
} }
React.useEffect(() => { React.useEffect(() => {
if (email.length === 0) { if (email.length === 0) {
setEmailAvailable(null) setEmailAvailable(null)
setValidFormat(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 // check if email is available
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
if (!validFormat) return if (!isEmailValid) {
return false
}
const request = await AuthModel.availability({ email }).catch((error) => { const request = await AuthModel.availability({ email }).catch((error) => {
antd.message.error(`Cannot check email availability: ${error.message}`) antd.message.error(`Cannot check email availability: ${error.message}`)
return false return false
}) })
if (request) { if (request) {
setEmailAvailable(!request.exist) setEmailAvailable(!request.exist)
if (request.exist) { if (request.exist) {
antd.message.error("Email is already in use") antd.message.error("Email is already in use")
props.updateValue(null) props.updateValue(null)
} else { } else {
props.updateValue(email) props.updateValue(email)
} }
} }
setLoading(false) setLoading(false)
}, 1000) }, 1000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [email]) }, [email])
return <div className="register_form_step_content"> return (
<antd.Input <div className="register_form_step_content">
defaultValue={props.currentValue} <antd.Input
placeholder="Email" defaultValue={props.currentValue}
onPressEnter={submit} placeholder="Email"
onChange={handleUpdate} onPressEnter={submit}
status={email.length == 0 ? "default" : loading ? "default" : (isValid() ? "success" : "error")} onChange={handleUpdate}
/> status={
</div> email.length == 0
? "default"
: loading
? "default"
: isValid()
? "success"
: "error"
}
/>
</div>
)
} }
export default { export default {
key: "email", key: "email",
title: "Step 3", title: "Step 3",
icon: "FiMail", 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.", description:
required: true, "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.",
content: EmailStepComponent, required: true,
} content: EmailStepComponent,
}

View File

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

View File

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

View File

@ -1,32 +1,55 @@
import React from "react" import React from "react"
import config from "@config"
import useRandomFeaturedWallpaperUrl from "@hooks/useRandomFeaturedWallpaperUrl" 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" import "./index.mobile.less"
export default (props) => { const AuthPage = (props) => {
const randomWallpaperURL = useRandomFeaturedWallpaperUrl() const randomWallpaperURL = useRandomFeaturedWallpaperUrl()
const [activeKey, setActiveKey] = useUrlQueryActiveKey({
React.useEffect(() => { defaultKey: "selector",
if (app.userData) { })
app.navigation.goMain()
} else {
app.auth.login()
}
}, [])
return ( return (
<div className="loginPage"> <div className="login-page">
<div <div
style={{ style={{
backgroundImage: `url(${randomWallpaperURL})`, backgroundImage: `url(${randomWallpaperURL})`,
}} }}
className="wallpaper" className="wallpaper"
> />
{/* <p>
{wallpaperData?.author ? wallpaperData.author : null} <div className="login-page-card">
</p> */} <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>
</div> </div>
) )
} }
export default AuthPage

View File

@ -1,22 +1,89 @@
.loginPage { .login-page {
position: relative; position: relative;
width: 100%; display: flex;
height: 100vh; flex-direction: column;
height: 100dvh;
.wallpaper { align-items: center;
position: absolute; justify-content: center;
top: 0; width: 100%;
left: 0; height: 100vh;
height: 100dvh;
width: 100%; .wallpaper {
height: 100vh; position: fixed;
height: 100dvh;
background-position: center; top: 0;
background-size: cover; left: 0;
background-repeat: no-repeat;
} 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(() => { React.useEffect(() => {
setVisible(false) setVisible(false)
setCurrentLineIndex(0) setCurrentLineIndex(0)
// set scroll top to 0
textRef.current.scrollTop = 0
}, [playerState.track_manifest]) }, [playerState.track_manifest])
React.useEffect(() => { React.useEffect(() => {

View File

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

View File

@ -1,60 +1,84 @@
import bcrypt from "bcrypt" import bcrypt from "bcrypt"
import { User } from "@db_models" import { User } from "@db_models"
import requiredFields from "@shared-utils/requiredFields"
import Account from "@classes/account" import Account from "@classes/account"
import requiredFields from "@shared-utils/requiredFields"
import verifyTurnstileToken from "@utils/verifyTurnstileToken"
export default async (payload) => { 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) { if (ToBoolean(accept_tos) !== true) {
throw new OperationError(400, "You must accept the terms of service in order to create an account.") 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 turnstileResponse = await verifyTurnstileToken(captcha)
const existentUser = await User
.findOne({ username: username })
if (existentUser) { if (turnstileResponse.success !== true) {
throw new OperationError(400, "User already exists") throw new OperationError(400, "Invalid captcha token")
} }
// check if the email is already in use await Account.usernameMeetPolicy(username)
const existentEmail = await User
.findOne({ email: email })
.select("+email")
if (existentEmail) { // check if username is already taken
throw new OperationError(400, "Email already in use") const existentUser = await User.findOne({ username: username })
}
await Account.passwordMeetPolicy(password) if (existentUser) {
throw new OperationError(400, "User already exists")
}
// hash the password // check if the email is already in use
const hash = bcrypt.hashSync(password, parseInt(process.env.BCRYPT_ROUNDS ?? 3)) const existentEmail = await User.findOne({ email: email }).select("+email")
let user = new User({ if (existentEmail) {
username: username, throw new OperationError(400, "Email already in use")
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.passwordMeetPolicy(password)
await Account.sendActivationCode(user._id.toString()) // hash the password
const hash = bcrypt.hashSync(
password,
parseInt(process.env.BCRYPT_ROUNDS ?? 3),
)
return { let user = new User({
activation_required: true, username: username,
user: user, 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
}