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

@ -19,7 +19,9 @@ const EmailStepComponent = (props) => {
} }
const submit = () => { const submit = () => {
if (!isValid()) return if (!isValid()) {
return false
}
props.onPressEnter() props.onPressEnter()
} }
@ -40,11 +42,14 @@ const EmailStepComponent = (props) => {
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}`)
@ -69,22 +74,33 @@ const EmailStepComponent = (props) => {
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [email]) }, [email])
return <div className="register_form_step_content"> return (
<div className="register_form_step_content">
<antd.Input <antd.Input
defaultValue={props.currentValue} defaultValue={props.currentValue}
placeholder="Email" placeholder="Email"
onPressEnter={submit} onPressEnter={submit}
onChange={handleUpdate} onChange={handleUpdate}
status={email.length == 0 ? "default" : loading ? "default" : (isValid() ? "success" : "error")} status={
email.length == 0
? "default"
: loading
? "default"
: isValid()
? "success"
: "error"
}
/> />
</div> </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:
"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, required: true,
content: EmailStepComponent, content: EmailStepComponent,
} }

View File

@ -1,32 +1,31 @@
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(" ")
@ -35,29 +34,30 @@ function composeConfirmationCheckboxLabel(documents) {
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 (
<antd.Button
key={key} key={key}
onClick={() => { onClick={() => {
app.layout.modal.open(key, MarkdownReader, { app.layout.modal.open(key, MarkdownReader, {
includeCloseButton: true, includeCloseButton: true,
frameContentStyle: FrameStyle, frameContentStyle: FrameStyle,
props: { props: {
url: value url: value,
} },
}) })
}} }}
> >
Read {LegalDocumentsDecorators[key] ?? `document (${key})`} Read {LegalDocumentsDecorators[key] ?? `document (${key})`}
</antd.Button> </antd.Button>
}) )
} })}
<antd.Checkbox <antd.Checkbox
defaultChecked={props.currentValue} defaultChecked={props.currentValue}
@ -68,6 +68,7 @@ const TermsOfServiceStepComponent = (props) => {
{composeConfirmationCheckboxLabel(legalDocuments)} {composeConfirmationCheckboxLabel(legalDocuments)}
</antd.Checkbox> </antd.Checkbox>
</div> </div>
)
} }
export default { export default {

View File

@ -6,9 +6,14 @@ import { Icons } from "@components/Icons"
const MainSelector = (props) => { const MainSelector = (props) => {
return ( return (
<> <>
{!app.isMobile && (
<div className="content_header"> <div className="content_header">
<img src={config.logo.alt} className="logo" /> <img
src={config.logo.alt}
className="logo"
/>
</div> </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,16 +1,24 @@
.loginPage { .login-page {
position: relative; position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%; width: 100%;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
.wallpaper { .wallpaper {
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
z-index: -1;
width: 100%; width: 100%;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
@ -19,4 +27,63 @@
background-size: cover; background-size: cover;
background-repeat: no-repeat; 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,32 +1,52 @@
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.",
)
}
if (!captcha) {
throw new OperationError(400, "Captcha token is required")
}
const turnstileResponse = await verifyTurnstileToken(captcha)
if (turnstileResponse.success !== true) {
throw new OperationError(400, "Invalid captcha token")
} }
await Account.usernameMeetPolicy(username) await Account.usernameMeetPolicy(username)
// check if username is already taken // check if username is already taken
const existentUser = await User const existentUser = await User.findOne({ username: username })
.findOne({ username: username })
if (existentUser) { if (existentUser) {
throw new OperationError(400, "User already exists") throw new OperationError(400, "User already exists")
} }
// check if the email is already in use // check if the email is already in use
const existentEmail = await User const existentEmail = await User.findOne({ email: email }).select("+email")
.findOne({ email: email })
.select("+email")
if (existentEmail) { if (existentEmail) {
throw new OperationError(400, "Email already in use") throw new OperationError(400, "Email already in use")
@ -35,14 +55,18 @@ export default async (payload) => {
await Account.passwordMeetPolicy(password) await Account.passwordMeetPolicy(password)
// hash the password // hash the password
const hash = bcrypt.hashSync(password, parseInt(process.env.BCRYPT_ROUNDS ?? 3)) const hash = bcrypt.hashSync(
password,
parseInt(process.env.BCRYPT_ROUNDS ?? 3),
)
let user = new User({ let user = new User({
username: username, username: username,
password: hash, password: hash,
email: email, email: email,
public_name: public_name, public_name: public_name,
avatar: avatar ?? `https://api.dicebear.com/7.x/thumbs/svg?seed=${username}`, avatar:
avatar ?? `https://api.dicebear.com/7.x/thumbs/svg?seed=${username}`,
roles: roles, roles: roles,
created_at: new Date().getTime(), created_at: new Date().getTime(),
accept_tos: accept_tos, accept_tos: accept_tos,

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
}