mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-29 02:14:43 +03:00
feat: add captcha support for sign in/up (#4582)
This commit is contained in:
parent
524e48c8e6
commit
63ca9671be
@ -11,3 +11,4 @@ ENABLE_CLOUD=
|
|||||||
ENABLE_MOVE_DATABASE=
|
ENABLE_MOVE_DATABASE=
|
||||||
SHOULD_REPORT_TRACE=
|
SHOULD_REPORT_TRACE=
|
||||||
TRACE_REPORT_ENDPOINT=
|
TRACE_REPORT_ENDPOINT=
|
||||||
|
CAPTCHA_SITE_KEY=
|
4
.github/actions/deploy/deploy.mjs
vendored
4
.github/actions/deploy/deploy.mjs
vendored
@ -13,6 +13,8 @@ const {
|
|||||||
R2_ACCESS_KEY_ID,
|
R2_ACCESS_KEY_ID,
|
||||||
R2_SECRET_ACCESS_KEY,
|
R2_SECRET_ACCESS_KEY,
|
||||||
R2_BUCKET,
|
R2_BUCKET,
|
||||||
|
ENABLE_CAPTCHA,
|
||||||
|
CAPTCHA_TURNSTILE_SECRET,
|
||||||
OAUTH_EMAIL_SENDER,
|
OAUTH_EMAIL_SENDER,
|
||||||
OAUTH_EMAIL_LOGIN,
|
OAUTH_EMAIL_LOGIN,
|
||||||
OAUTH_EMAIL_PASSWORD,
|
OAUTH_EMAIL_PASSWORD,
|
||||||
@ -81,6 +83,8 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
`--set graphql.replicaCount=${graphqlReplicaCount}`,
|
`--set graphql.replicaCount=${graphqlReplicaCount}`,
|
||||||
`--set-string graphql.image.tag="${imageTag}"`,
|
`--set-string graphql.image.tag="${imageTag}"`,
|
||||||
`--set graphql.app.host=${host}`,
|
`--set graphql.app.host=${host}`,
|
||||||
|
`--set graphql.app.captcha.enabled=${ENABLE_CAPTCHA}`,
|
||||||
|
`--set-string graphql.app.captcha.turnstile.secret="${CAPTCHA_TURNSTILE_SECRET}"`,
|
||||||
`--set graphql.app.objectStorage.r2.enabled=true`,
|
`--set graphql.app.objectStorage.r2.enabled=true`,
|
||||||
`--set-string graphql.app.objectStorage.r2.accountId="${R2_ACCOUNT_ID}"`,
|
`--set-string graphql.app.objectStorage.r2.accountId="${R2_ACCOUNT_ID}"`,
|
||||||
`--set-string graphql.app.objectStorage.r2.accessKeyId="${R2_ACCESS_KEY_ID}"`,
|
`--set-string graphql.app.objectStorage.r2.accessKeyId="${R2_ACCESS_KEY_ID}"`,
|
||||||
|
9
.github/helm/affine/charts/graphql/templates/captcha-secret.yaml
vendored
Normal file
9
.github/helm/affine/charts/graphql/templates/captcha-secret.yaml
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{{- if .Values.app.captcha.enabled -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: "{{ .Values.app.captcha.secretName }}"
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
turnstileSecret: {{ .Values.app.captcha.turnstile.secret | b64enc }}
|
||||||
|
{{- end }}
|
@ -73,6 +73,8 @@ spec:
|
|||||||
value: "{{ .Values.app.host }}"
|
value: "{{ .Values.app.host }}"
|
||||||
- name: ENABLE_R2_OBJECT_STORAGE
|
- name: ENABLE_R2_OBJECT_STORAGE
|
||||||
value: "{{ .Values.app.objectStorage.r2.enabled }}"
|
value: "{{ .Values.app.objectStorage.r2.enabled }}"
|
||||||
|
- name: ENABLE_CAPTCHA
|
||||||
|
value: "{{ .Values.app.captcha.enabled }}"
|
||||||
- name: OAUTH_EMAIL_SENDER
|
- name: OAUTH_EMAIL_SENDER
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@ -126,6 +128,13 @@ spec:
|
|||||||
name: "{{ .Values.app.objectStorage.r2.secretName }}"
|
name: "{{ .Values.app.objectStorage.r2.secretName }}"
|
||||||
key: bucket
|
key: bucket
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if .Values.app.captcha.enabled }}
|
||||||
|
- name: CAPTCHA_TURNSTILE_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: "{{ .Values.app.captcha.secretName }}"
|
||||||
|
key: turnstileSecret
|
||||||
|
{{ end }}
|
||||||
{{ if .Values.app.oauth.google.enabled }}
|
{{ if .Values.app.oauth.google.enabled }}
|
||||||
- name: OAUTH_GOOGLE_CLIENT_ID
|
- name: OAUTH_GOOGLE_CLIENT_ID
|
||||||
valueFrom:
|
valueFrom:
|
||||||
|
@ -22,6 +22,11 @@ app:
|
|||||||
secretName: jwt-private-key
|
secretName: jwt-private-key
|
||||||
# base64 encoded ecdsa private key
|
# base64 encoded ecdsa private key
|
||||||
privateKey: ''
|
privateKey: ''
|
||||||
|
captcha:
|
||||||
|
enable: false
|
||||||
|
secretName: captcha
|
||||||
|
turnstile:
|
||||||
|
secret: ''
|
||||||
objectStorage:
|
objectStorage:
|
||||||
r2:
|
r2:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
3
.github/workflows/deploy.yml
vendored
3
.github/workflows/deploy.yml
vendored
@ -57,6 +57,7 @@ jobs:
|
|||||||
BUILD_TYPE_OVERRIDE: ${{ github.event.inputs.flavor }}
|
BUILD_TYPE_OVERRIDE: ${{ github.event.inputs.flavor }}
|
||||||
SHOULD_REPORT_TRACE: true
|
SHOULD_REPORT_TRACE: true
|
||||||
TRACE_REPORT_ENDPOINT: ${{ secrets.TRACE_REPORT_ENDPOINT }}
|
TRACE_REPORT_ENDPOINT: ${{ secrets.TRACE_REPORT_ENDPOINT }}
|
||||||
|
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
|
||||||
- name: Upload core artifact
|
- name: Upload core artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
@ -197,6 +198,8 @@ jobs:
|
|||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
|
ENABLE_CAPTCHA: true
|
||||||
|
CAPTCHA_TURNSTILE_SECRET: ${{ secrets.CAPTCHA_TURNSTILE_SECRET }}
|
||||||
OAUTH_EMAIL_SENDER: ${{ secrets.OAUTH_EMAIL_SENDER }}
|
OAUTH_EMAIL_SENDER: ${{ secrets.OAUTH_EMAIL_SENDER }}
|
||||||
OAUTH_EMAIL_LOGIN: ${{ secrets.OAUTH_EMAIL_LOGIN }}
|
OAUTH_EMAIL_LOGIN: ${{ secrets.OAUTH_EMAIL_LOGIN }}
|
||||||
OAUTH_EMAIL_PASSWORD: ${{ secrets.OAUTH_EMAIL_PASSWORD }}
|
OAUTH_EMAIL_PASSWORD: ${{ secrets.OAUTH_EMAIL_PASSWORD }}
|
||||||
|
25
Cargo.lock
generated
25
Cargo.lock
generated
@ -41,8 +41,10 @@ dependencies = [
|
|||||||
"notify",
|
"notify",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha3",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
@ -56,12 +58,16 @@ version = "0.0.0"
|
|||||||
name = "affine_storage"
|
name = "affine_storage"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"jwst-codec",
|
"jwst-codec",
|
||||||
"jwst-core",
|
"jwst-core",
|
||||||
"jwst-storage",
|
"jwst-storage",
|
||||||
"napi",
|
"napi",
|
||||||
"napi-build",
|
"napi-build",
|
||||||
"napi-derive",
|
"napi-derive",
|
||||||
|
"rand",
|
||||||
|
"sha3",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1188,6 +1194,15 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "keccak"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f6d5ed8676d904364de097082f4e7d240b571b67989ced0240f08b7f966f940"
|
||||||
|
dependencies = [
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kqueue"
|
name = "kqueue"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
@ -2284,6 +2299,16 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha3"
|
||||||
|
version = "0.10.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
"keccak",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sharded-slab"
|
name = "sharded-slab"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
@ -359,6 +359,9 @@ export const createConfiguration: (
|
|||||||
'process.env.TRACE_REPORT_ENDPOINT': JSON.stringify(
|
'process.env.TRACE_REPORT_ENDPOINT': JSON.stringify(
|
||||||
process.env.TRACE_REPORT_ENDPOINT
|
process.env.TRACE_REPORT_ENDPOINT
|
||||||
),
|
),
|
||||||
|
'process.env.CAPTCHA_SITE_KEY': JSON.stringify(
|
||||||
|
process.env.CAPTCHA_SITE_KEY
|
||||||
|
),
|
||||||
runtimeConfig: JSON.stringify(runtimeConfig),
|
runtimeConfig: JSON.stringify(runtimeConfig),
|
||||||
}),
|
}),
|
||||||
new CopyPlugin({
|
new CopyPlugin({
|
||||||
|
@ -29,6 +29,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
enableMoveDatabase: false,
|
enableMoveDatabase: false,
|
||||||
enableNotificationCenter: true,
|
enableNotificationCenter: true,
|
||||||
enableCloud: true,
|
enableCloud: true,
|
||||||
|
enableCaptcha: true,
|
||||||
enableEnhanceShareMode: false,
|
enableEnhanceShareMode: false,
|
||||||
serverUrlPrefix: 'https://app.affine.pro',
|
serverUrlPrefix: 'https://app.affine.pro',
|
||||||
editorFlags,
|
editorFlags,
|
||||||
@ -62,6 +63,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
enableMoveDatabase: false,
|
enableMoveDatabase: false,
|
||||||
enableNotificationCenter: true,
|
enableNotificationCenter: true,
|
||||||
enableCloud: true,
|
enableCloud: true,
|
||||||
|
enableCaptcha: true,
|
||||||
enableEnhanceShareMode: false,
|
enableEnhanceShareMode: false,
|
||||||
serverUrlPrefix: 'https://affine.fail',
|
serverUrlPrefix: 'https://affine.fail',
|
||||||
editorFlags,
|
editorFlags,
|
||||||
@ -107,6 +109,11 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
enableCloud: process.env.ENABLE_CLOUD
|
enableCloud: process.env.ENABLE_CLOUD
|
||||||
? process.env.ENABLE_CLOUD === 'true'
|
? process.env.ENABLE_CLOUD === 'true'
|
||||||
: currentBuildPreset.enableCloud,
|
: currentBuildPreset.enableCloud,
|
||||||
|
enableCaptcha: process.env.ENABLE_CAPTCHA
|
||||||
|
? process.env.ENABLE_CAPTCHA === 'true'
|
||||||
|
: buildFlags.mode === 'development'
|
||||||
|
? false
|
||||||
|
: currentBuildPreset.enableCaptcha,
|
||||||
enableEnhanceShareMode: process.env.ENABLE_ENHANCE_SHARE_MODE
|
enableEnhanceShareMode: process.env.ENABLE_ENHANCE_SHARE_MODE
|
||||||
? process.env.ENABLE_ENHANCE_SHARE_MODE === 'true'
|
? process.env.ENABLE_ENHANCE_SHARE_MODE === 'true'
|
||||||
: currentBuildPreset.enableEnhanceShareMode,
|
: currentBuildPreset.enableEnhanceShareMode,
|
||||||
|
@ -37,6 +37,7 @@
|
|||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/server": "^11.11.0",
|
"@emotion/server": "^11.11.0",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@marsidev/react-turnstile": "^0.3.1",
|
||||||
"@mui/material": "^5.14.13",
|
"@mui/material": "^5.14.13",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@react-hookz/web": "^23.1.0",
|
"@react-hookz/web": "^23.1.0",
|
||||||
@ -61,7 +62,7 @@
|
|||||||
"react-router-dom": "^6.16.0",
|
"react-router-dom": "^6.16.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"ses": "^0.18.8",
|
"ses": "^0.18.8",
|
||||||
"swr": "2.2.0",
|
"swr": "2.2.4",
|
||||||
"valtio": "^1.11.2",
|
"valtio": "^1.11.2",
|
||||||
"y-protocols": "^1.0.6",
|
"y-protocols": "^1.0.6",
|
||||||
"yjs": "^13.6.8",
|
"yjs": "^13.6.8",
|
||||||
|
@ -13,6 +13,7 @@ import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-s
|
|||||||
import type { AuthPanelProps } from './index';
|
import type { AuthPanelProps } from './index';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import { useAuth } from './use-auth';
|
import { useAuth } from './use-auth';
|
||||||
|
import { Captcha, useCaptcha } from './use-captcha';
|
||||||
|
|
||||||
export const AfterSignInSendEmail = ({
|
export const AfterSignInSendEmail = ({
|
||||||
setAuthState,
|
setAuthState,
|
||||||
@ -21,6 +22,7 @@ export const AfterSignInSendEmail = ({
|
|||||||
}: AuthPanelProps) => {
|
}: AuthPanelProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const loginStatus = useCurrentLoginStatus();
|
const loginStatus = useCurrentLoginStatus();
|
||||||
|
const [verifyToken, challenge] = useCaptcha();
|
||||||
|
|
||||||
const { resendCountDown, allowSendEmail, signIn } = useAuth();
|
const { resendCountDown, allowSendEmail, signIn } = useAuth();
|
||||||
if (loginStatus === 'authenticated') {
|
if (loginStatus === 'authenticated') {
|
||||||
@ -28,8 +30,10 @@ export const AfterSignInSendEmail = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onResendClick = useCallback(async () => {
|
const onResendClick = useCallback(async () => {
|
||||||
await signIn(email);
|
if (verifyToken) {
|
||||||
}, [email, signIn]);
|
await signIn(email, verifyToken, challenge);
|
||||||
|
}
|
||||||
|
}, [challenge, email, signIn, verifyToken]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -37,7 +41,7 @@ export const AfterSignInSendEmail = ({
|
|||||||
title={t['com.affine.auth.sign.in']()}
|
title={t['com.affine.auth.sign.in']()}
|
||||||
subTitle={t['com.affine.auth.sign.in.sent.email.subtitle']()}
|
subTitle={t['com.affine.auth.sign.in.sent.email.subtitle']()}
|
||||||
/>
|
/>
|
||||||
<AuthContent style={{ height: 162 }}>
|
<AuthContent style={{ height: 100 }}>
|
||||||
{t['com.affine.auth.sign.sent.email.message.start']()}
|
{t['com.affine.auth.sign.sent.email.message.start']()}
|
||||||
<a href={`mailto:${email}`}>{email}</a>
|
<a href={`mailto:${email}`}>{email}</a>
|
||||||
{t['com.affine.auth.sign.sent.email.message.end']()}
|
{t['com.affine.auth.sign.sent.email.message.end']()}
|
||||||
@ -45,9 +49,18 @@ export const AfterSignInSendEmail = ({
|
|||||||
|
|
||||||
<div className={style.resendWrapper}>
|
<div className={style.resendWrapper}>
|
||||||
{allowSendEmail ? (
|
{allowSendEmail ? (
|
||||||
<Button type="plain" size="large" onClick={onResendClick}>
|
<>
|
||||||
{t['com.affine.auth.sign.auth.code.resend.hint']()}
|
<Captcha />
|
||||||
</Button>
|
<Button
|
||||||
|
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
|
||||||
|
disabled={!verifyToken}
|
||||||
|
type="plain"
|
||||||
|
size="large"
|
||||||
|
onClick={onResendClick}
|
||||||
|
>
|
||||||
|
{t['com.affine.auth.sign.auth.code.resend.hint']()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="resend-code-hint">
|
<span className="resend-code-hint">
|
||||||
|
@ -12,6 +12,7 @@ import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-s
|
|||||||
import type { AuthPanelProps } from './index';
|
import type { AuthPanelProps } from './index';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import { useAuth } from './use-auth';
|
import { useAuth } from './use-auth';
|
||||||
|
import { Captcha, useCaptcha } from './use-captcha';
|
||||||
|
|
||||||
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
|
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
|
||||||
setAuthState,
|
setAuthState,
|
||||||
@ -20,6 +21,7 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const loginStatus = useCurrentLoginStatus();
|
const loginStatus = useCurrentLoginStatus();
|
||||||
|
const [verifyToken, challenge] = useCaptcha();
|
||||||
|
|
||||||
const { resendCountDown, allowSendEmail, signUp } = useAuth();
|
const { resendCountDown, allowSendEmail, signUp } = useAuth();
|
||||||
|
|
||||||
@ -28,8 +30,10 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onResendClick = useCallback(async () => {
|
const onResendClick = useCallback(async () => {
|
||||||
await signUp(email);
|
if (verifyToken) {
|
||||||
}, [email, signUp]);
|
await signUp(email, verifyToken, challenge);
|
||||||
|
}
|
||||||
|
}, [challenge, email, signUp, verifyToken]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -37,7 +41,7 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
|
|||||||
title={t['com.affine.auth.sign.up']()}
|
title={t['com.affine.auth.sign.up']()}
|
||||||
subTitle={t['com.affine.auth.sign.up.sent.email.subtitle']()}
|
subTitle={t['com.affine.auth.sign.up.sent.email.subtitle']()}
|
||||||
/>
|
/>
|
||||||
<AuthContent style={{ height: 162 }}>
|
<AuthContent style={{ height: 100 }}>
|
||||||
{t['com.affine.auth.sign.sent.email.message.start']()}
|
{t['com.affine.auth.sign.sent.email.message.start']()}
|
||||||
<a href={`mailto:${email}`}>{email}</a>
|
<a href={`mailto:${email}`}>{email}</a>
|
||||||
{t['com.affine.auth.sign.sent.email.message.end']()}
|
{t['com.affine.auth.sign.sent.email.message.end']()}
|
||||||
@ -45,9 +49,18 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
|
|||||||
|
|
||||||
<div className={style.resendWrapper}>
|
<div className={style.resendWrapper}>
|
||||||
{allowSendEmail ? (
|
{allowSendEmail ? (
|
||||||
<Button type="plain" size="large" onClick={onResendClick}>
|
<>
|
||||||
{t['com.affine.auth.sign.auth.code.resend.hint']()}
|
<Captcha />
|
||||||
</Button>
|
<Button
|
||||||
|
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
|
||||||
|
disabled={!verifyToken}
|
||||||
|
type="plain"
|
||||||
|
size="large"
|
||||||
|
onClick={onResendClick}
|
||||||
|
>
|
||||||
|
{t['com.affine.auth.sign.auth.code.resend.hint']()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="resend-code-hint">
|
<span className="resend-code-hint">
|
||||||
|
@ -18,6 +18,7 @@ import { emailRegex } from '../../../utils/email-regex';
|
|||||||
import type { AuthPanelProps } from './index';
|
import type { AuthPanelProps } from './index';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
||||||
|
import { Captcha, useCaptcha } from './use-captcha';
|
||||||
|
|
||||||
function validateEmail(email: string) {
|
function validateEmail(email: string) {
|
||||||
return emailRegex.test(email);
|
return emailRegex.test(email);
|
||||||
@ -31,6 +32,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const loginStatus = useCurrentLoginStatus();
|
const loginStatus = useCurrentLoginStatus();
|
||||||
|
const [verifyToken, challenge] = useCaptcha();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isMutating: isSigningIn,
|
isMutating: isSigningIn,
|
||||||
@ -78,20 +80,33 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
}
|
}
|
||||||
setAuthEmail(email);
|
setAuthEmail(email);
|
||||||
|
|
||||||
if (user) {
|
if (verifyToken) {
|
||||||
const res = await signIn(email);
|
if (user) {
|
||||||
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
const res = await signIn(email, verifyToken, challenge);
|
||||||
return setAuthState('noAccess');
|
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
||||||
|
return setAuthState('noAccess');
|
||||||
|
}
|
||||||
|
setAuthState('afterSignInSendEmail');
|
||||||
|
} else {
|
||||||
|
const res = await signUp(email, verifyToken, challenge);
|
||||||
|
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
||||||
|
return setAuthState('noAccess');
|
||||||
|
} else if (!res || res.status >= 400 || res.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAuthState('afterSignUpSendEmail');
|
||||||
}
|
}
|
||||||
setAuthState('afterSignInSendEmail');
|
|
||||||
} else {
|
|
||||||
const res = await signUp(email);
|
|
||||||
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
|
||||||
return setAuthState('noAccess');
|
|
||||||
}
|
|
||||||
setAuthState('afterSignUpSendEmail');
|
|
||||||
}
|
}
|
||||||
}, [email, setAuthEmail, setAuthState, signIn, signUp, verifyUser]);
|
}, [
|
||||||
|
challenge,
|
||||||
|
email,
|
||||||
|
setAuthEmail,
|
||||||
|
setAuthState,
|
||||||
|
signIn,
|
||||||
|
signUp,
|
||||||
|
verifyToken,
|
||||||
|
verifyUser,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -133,41 +148,45 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
onEnter={onContinue}
|
onEnter={onContinue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
{verifyToken ? null : <Captcha />}
|
||||||
size="extraLarge"
|
|
||||||
data-testid="continue-login-button"
|
{verifyToken ? (
|
||||||
block
|
<Button
|
||||||
loading={isMutating || isSigningIn}
|
size="extraLarge"
|
||||||
disabled={!allowSendEmail}
|
data-testid="continue-login-button"
|
||||||
icon={
|
block
|
||||||
allowSendEmail || isMutating ? (
|
loading={isMutating || isSigningIn}
|
||||||
<ArrowDownBigIcon
|
disabled={!allowSendEmail}
|
||||||
width={20}
|
icon={
|
||||||
height={20}
|
allowSendEmail || isMutating ? (
|
||||||
style={{
|
<ArrowDownBigIcon
|
||||||
transform: 'rotate(-90deg)',
|
width={20}
|
||||||
color: 'var(--affine-blue)',
|
height={20}
|
||||||
}}
|
style={{
|
||||||
/>
|
transform: 'rotate(-90deg)',
|
||||||
) : (
|
color: 'var(--affine-blue)',
|
||||||
<CountDownRender
|
}}
|
||||||
className={style.resendCountdownInButton}
|
/>
|
||||||
timeLeft={resendCountDown}
|
) : (
|
||||||
/>
|
<CountDownRender
|
||||||
)
|
className={style.resendCountdownInButton}
|
||||||
}
|
timeLeft={resendCountDown}
|
||||||
iconPosition="end"
|
/>
|
||||||
onClick={onContinue}
|
)
|
||||||
>
|
}
|
||||||
{t['com.affine.auth.sign.email.continue']()}
|
iconPosition="end"
|
||||||
</Button>
|
onClick={onContinue}
|
||||||
|
>
|
||||||
|
{t['com.affine.auth.sign.email.continue']()}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className={style.authMessage}>
|
<div className={style.authMessage}>
|
||||||
{/*prettier-ignore*/}
|
{/*prettier-ignore*/}
|
||||||
<Trans i18nKey="com.affine.auth.sign.message">
|
<Trans i18nKey="com.affine.auth.sign.message">
|
||||||
By clicking "Continue with Google/Email" above, you acknowledge that
|
By clicking "Continue with Google/Email" above, you acknowledge that
|
||||||
you agree to AFFiNE's <a href="https://affine.pro/terms" target="_blank" rel="noreferrer">Terms of Conditions</a> and <a href="https://affine.pro/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>.
|
you agree to AFFiNE's <a href="https://affine.pro/terms" target="_blank" rel="noreferrer">Terms of Conditions</a> and <a href="https://affine.pro/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>.
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -4,6 +4,12 @@ export const authModalContent = style({
|
|||||||
marginTop: '30px',
|
marginTop: '30px',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const captchaWrapper = style({
|
||||||
|
margin: 'auto',
|
||||||
|
marginBottom: '4px',
|
||||||
|
textAlign: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
export const authMessage = style({
|
export const authMessage = style({
|
||||||
marginTop: '30px',
|
marginTop: '30px',
|
||||||
color: 'var(--affine-text-secondary-color)',
|
color: 'var(--affine-text-secondary-color)',
|
||||||
@ -28,8 +34,9 @@ export const forgetPasswordButton = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const resendWrapper = style({
|
export const resendWrapper = style({
|
||||||
height: 32,
|
height: 77,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 30,
|
marginTop: 30,
|
||||||
|
@ -63,7 +63,7 @@ export const useAuth = () => {
|
|||||||
const startResendCountDown = useSetAtom(countDownAtom);
|
const startResendCountDown = useSetAtom(countDownAtom);
|
||||||
|
|
||||||
const signIn = useCallback(
|
const signIn = useCallback(
|
||||||
async (email: string) => {
|
async (email: string, verifyToken: string, challenge?: string) => {
|
||||||
setAuthStore(prev => {
|
setAuthStore(prev => {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@ -71,11 +71,20 @@ export const useAuth = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await signInCloud('email', {
|
const res = await signInCloud(
|
||||||
email: email,
|
'email',
|
||||||
callbackUrl: '/auth/signIn',
|
{
|
||||||
redirect: false,
|
email: email,
|
||||||
}).catch(console.error);
|
callbackUrl: '/auth/signIn',
|
||||||
|
redirect: false,
|
||||||
|
},
|
||||||
|
challenge
|
||||||
|
? {
|
||||||
|
challenge,
|
||||||
|
token: verifyToken,
|
||||||
|
}
|
||||||
|
: { token: verifyToken }
|
||||||
|
).catch(console.error);
|
||||||
|
|
||||||
handleSendEmailError(res, pushNotification);
|
handleSendEmailError(res, pushNotification);
|
||||||
|
|
||||||
@ -93,7 +102,7 @@ export const useAuth = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const signUp = useCallback(
|
const signUp = useCallback(
|
||||||
async (email: string) => {
|
async (email: string, verifyToken: string, challenge?: string) => {
|
||||||
setAuthStore(prev => {
|
setAuthStore(prev => {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@ -101,11 +110,20 @@ export const useAuth = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await signInCloud('email', {
|
const res = await signInCloud(
|
||||||
email: email,
|
'email',
|
||||||
callbackUrl: '/auth/signUp',
|
{
|
||||||
redirect: false,
|
email: email,
|
||||||
}).catch(console.error);
|
callbackUrl: '/auth/signUp',
|
||||||
|
redirect: false,
|
||||||
|
},
|
||||||
|
challenge
|
||||||
|
? {
|
||||||
|
challenge,
|
||||||
|
token: verifyToken,
|
||||||
|
}
|
||||||
|
: { token: verifyToken }
|
||||||
|
).catch(console.error);
|
||||||
|
|
||||||
handleSendEmailError(res, pushNotification);
|
handleSendEmailError(res, pushNotification);
|
||||||
|
|
||||||
|
105
apps/core/src/components/affine/auth/use-captcha.tsx
Normal file
105
apps/core/src/components/affine/auth/use-captcha.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { fetchWithTraceReport } from '@affine/graphql';
|
||||||
|
import { Turnstile } from '@marsidev/react-turnstile';
|
||||||
|
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import * as style from './style.css';
|
||||||
|
|
||||||
|
type Challenge = {
|
||||||
|
challenge: string;
|
||||||
|
resource: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const challengeFetcher = async (url: string) => {
|
||||||
|
if (!environment.isDesktop) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetchWithTraceReport(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to fetch challenge');
|
||||||
|
}
|
||||||
|
const challenge = (await res.json()) as Challenge;
|
||||||
|
if (!challenge || !challenge.challenge || !challenge.resource) {
|
||||||
|
throw new Error('Invalid challenge');
|
||||||
|
}
|
||||||
|
|
||||||
|
return challenge;
|
||||||
|
};
|
||||||
|
const generateChallengeResponse = async (challenge: string) => {
|
||||||
|
if (!environment.isDesktop) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await window.apis?.ui?.getChallengeResponse(challenge);
|
||||||
|
};
|
||||||
|
|
||||||
|
const captchaAtom = atom<string | undefined>(undefined);
|
||||||
|
const responseAtom = atom<string | undefined>(undefined);
|
||||||
|
|
||||||
|
export const Captcha = () => {
|
||||||
|
const setCaptcha = useSetAtom(captchaAtom);
|
||||||
|
const [response] = useAtom(responseAtom);
|
||||||
|
|
||||||
|
if (!runtimeConfig.enableCaptcha) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (environment.isDesktop) {
|
||||||
|
if (response) {
|
||||||
|
return <div className={style.captchaWrapper}>Making Challenge</div>;
|
||||||
|
} else {
|
||||||
|
return <div className={style.captchaWrapper}>Verified Client</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Turnstile
|
||||||
|
className={style.captchaWrapper}
|
||||||
|
siteKey={process.env.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'}
|
||||||
|
onSuccess={setCaptcha}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCaptcha = (): [string | undefined, string?] => {
|
||||||
|
const [verifyToken] = useAtom(captchaAtom);
|
||||||
|
const [response, setResponse] = useAtom(responseAtom);
|
||||||
|
|
||||||
|
const { data: challenge } = useSWR('/api/auth/challenge', challengeFetcher, {
|
||||||
|
suspense: false,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
const prevChallenge = useRef('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
runtimeConfig.enableCaptcha &&
|
||||||
|
environment.isDesktop &&
|
||||||
|
challenge?.challenge &&
|
||||||
|
prevChallenge.current !== challenge.challenge
|
||||||
|
) {
|
||||||
|
prevChallenge.current = challenge.challenge;
|
||||||
|
generateChallengeResponse(challenge.resource)
|
||||||
|
.then(setResponse)
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error getting challenge response:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [challenge, setResponse]);
|
||||||
|
|
||||||
|
if (!runtimeConfig.enableCaptcha) {
|
||||||
|
return ['XXXX.DUMMY.TOKEN.XXXX'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (environment.isDesktop) {
|
||||||
|
if (response) {
|
||||||
|
return [response, challenge?.challenge];
|
||||||
|
} else {
|
||||||
|
return [undefined, challenge?.challenge];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [verifyToken];
|
||||||
|
};
|
7
apps/electron/src/main/ui/challenge.ts
Normal file
7
apps/electron/src/main/ui/challenge.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { mintChallengeResponse } from '@affine/native';
|
||||||
|
|
||||||
|
export const getChallengeResponse = async (resource: string) => {
|
||||||
|
// 20 bits challenge is a balance between security and user experience
|
||||||
|
// 20 bits challenge cost time is about 1-3s on m2 macbook air
|
||||||
|
return mintChallengeResponse(resource, 20);
|
||||||
|
};
|
@ -4,6 +4,7 @@ import { getLinkPreview } from 'link-preview-js';
|
|||||||
import { isMacOS } from '../../shared/utils';
|
import { isMacOS } from '../../shared/utils';
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
import type { NamespaceHandlers } from '../type';
|
import type { NamespaceHandlers } from '../type';
|
||||||
|
import { getChallengeResponse } from './challenge';
|
||||||
import { getGoogleOauthCode } from './google-auth';
|
import { getGoogleOauthCode } from './google-auth';
|
||||||
|
|
||||||
export const uiHandlers = {
|
export const uiHandlers = {
|
||||||
@ -45,6 +46,9 @@ export const uiHandlers = {
|
|||||||
getGoogleOauthCode: async () => {
|
getGoogleOauthCode: async () => {
|
||||||
return getGoogleOauthCode();
|
return getGoogleOauthCode();
|
||||||
},
|
},
|
||||||
|
getChallengeResponse: async (_, challenge: string) => {
|
||||||
|
return getChallengeResponse(challenge);
|
||||||
|
},
|
||||||
getBookmarkDataByLink: async (_, link: string) => {
|
getBookmarkDataByLink: async (_, link: string) => {
|
||||||
if (
|
if (
|
||||||
(link.startsWith('https://x.com/') ||
|
(link.startsWith('https://x.com/') ||
|
||||||
|
@ -3,4 +3,4 @@ NEXTAUTH_URL="http://localhost:8080"
|
|||||||
OAUTH_EMAIL_SENDER="noreply@toeverything.info"
|
OAUTH_EMAIL_SENDER="noreply@toeverything.info"
|
||||||
OAUTH_EMAIL_LOGIN=""
|
OAUTH_EMAIL_LOGIN=""
|
||||||
OAUTH_EMAIL_PASSWORD=""
|
OAUTH_EMAIL_PASSWORD=""
|
||||||
ENABLE_LOCAL_EMAIL="true"
|
ENABLE_LOCAL_EMAIL="true"
|
@ -320,6 +320,27 @@ export interface AFFiNEConfig {
|
|||||||
sender: string;
|
sender: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
captcha: {
|
||||||
|
/**
|
||||||
|
* whether to enable captcha
|
||||||
|
*/
|
||||||
|
enable: boolean;
|
||||||
|
turnstile: {
|
||||||
|
/**
|
||||||
|
* Cloudflare Turnstile CAPTCHA secret
|
||||||
|
* default value is demo api key, witch always return success
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
challenge: {
|
||||||
|
/**
|
||||||
|
* challenge bits length
|
||||||
|
* default value is 20, which can resolve in 0.5-3 second in M2 MacBook Air in single thread
|
||||||
|
* @default 20
|
||||||
|
*/
|
||||||
|
bits: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
doc: {
|
doc: {
|
||||||
|
@ -62,6 +62,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
|||||||
R2_OBJECT_STORAGE_ACCESS_KEY_ID: 'objectStorage.r2.accessKeyId',
|
R2_OBJECT_STORAGE_ACCESS_KEY_ID: 'objectStorage.r2.accessKeyId',
|
||||||
R2_OBJECT_STORAGE_SECRET_ACCESS_KEY: 'objectStorage.r2.secretAccessKey',
|
R2_OBJECT_STORAGE_SECRET_ACCESS_KEY: 'objectStorage.r2.secretAccessKey',
|
||||||
R2_OBJECT_STORAGE_BUCKET: 'objectStorage.r2.bucket',
|
R2_OBJECT_STORAGE_BUCKET: 'objectStorage.r2.bucket',
|
||||||
|
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
|
||||||
|
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
|
||||||
OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'],
|
OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'],
|
||||||
OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId',
|
OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId',
|
||||||
OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret',
|
OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret',
|
||||||
@ -147,6 +149,15 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
refreshTokenExpiresIn: parse('7d')! / 1000,
|
refreshTokenExpiresIn: parse('7d')! / 1000,
|
||||||
leeway: 60,
|
leeway: 60,
|
||||||
|
captcha: {
|
||||||
|
enable: false,
|
||||||
|
turnstile: {
|
||||||
|
secret: '1x0000000000000000000000000000000AA',
|
||||||
|
},
|
||||||
|
challenge: {
|
||||||
|
bits: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
privateKey: jwtKeyPair.privateKey,
|
privateKey: jwtKeyPair.privateKey,
|
||||||
publicKey: jwtKeyPair.publicKey,
|
publicKey: jwtKeyPair.publicKey,
|
||||||
enableSignup: true,
|
enableSignup: true,
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
All,
|
All,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
Next,
|
Next,
|
||||||
@ -17,12 +18,14 @@ import { hash, verify } from '@node-rs/argon2';
|
|||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import { pick } from 'lodash-es';
|
import { pick } from 'lodash-es';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth';
|
import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth';
|
||||||
import { AuthHandler } from 'next-auth/core';
|
import { AuthHandler } from 'next-auth/core';
|
||||||
|
|
||||||
import { Config } from '../../config';
|
import { Config } from '../../config';
|
||||||
import { Metrics } from '../../metrics/metrics';
|
import { Metrics } from '../../metrics/metrics';
|
||||||
import { PrismaService } from '../../prisma/service';
|
import { PrismaService } from '../../prisma/service';
|
||||||
|
import { SessionService } from '../../session';
|
||||||
import { AuthThrottlerGuard, Throttle } from '../../throttler';
|
import { AuthThrottlerGuard, Throttle } from '../../throttler';
|
||||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||||
import { AuthService } from './service';
|
import { AuthService } from './service';
|
||||||
@ -43,12 +46,28 @@ export class NextAuthController {
|
|||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
@Inject(NextAuthOptionsProvide)
|
@Inject(NextAuthOptionsProvide)
|
||||||
private readonly nextAuthOptions: NextAuthOptions,
|
private readonly nextAuthOptions: NextAuthOptions,
|
||||||
private readonly metrics: Metrics
|
private readonly metrics: Metrics,
|
||||||
|
private readonly session: SessionService
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
this.callbackSession = nextAuthOptions.callbacks!.session;
|
this.callbackSession = nextAuthOptions.callbacks!.session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(AuthThrottlerGuard)
|
||||||
|
@Throttle({
|
||||||
|
default: {
|
||||||
|
limit: 60,
|
||||||
|
ttl: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@Get('/challenge')
|
||||||
|
async getChallenge(@Res() res: Response) {
|
||||||
|
const challenge = nanoid();
|
||||||
|
const resource = nanoid();
|
||||||
|
await this.session.set(challenge, resource, 5 * 60 * 1000);
|
||||||
|
res.json({ challenge, resource });
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(AuthThrottlerGuard)
|
@UseGuards(AuthThrottlerGuard)
|
||||||
@Throttle({
|
@Throttle({
|
||||||
default: {
|
default: {
|
||||||
@ -128,6 +147,16 @@ export class NextAuthController {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
options.callbacks!.session = this.callbackSession;
|
options.callbacks!.session = this.callbackSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.config.auth.captcha.enable &&
|
||||||
|
req.method === 'POST' &&
|
||||||
|
action === 'signin'
|
||||||
|
) {
|
||||||
|
const isVerified = await this.verifyChallenge(req, res);
|
||||||
|
if (!isVerified) return;
|
||||||
|
}
|
||||||
|
|
||||||
const { status, headers, body, redirect, cookies } = await AuthHandler({
|
const { status, headers, body, redirect, cookies } = await AuthHandler({
|
||||||
req: {
|
req: {
|
||||||
body: req.body,
|
body: req.body,
|
||||||
@ -270,6 +299,44 @@ export class NextAuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async verifyChallenge(req: Request, res: Response): Promise<boolean> {
|
||||||
|
const challenge = req.query?.challenge;
|
||||||
|
if (typeof challenge === 'string' && challenge) {
|
||||||
|
const resource = await this.session.get(challenge);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
this.rejectResponse(res, 'Invalid Challenge');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChallengeVerified =
|
||||||
|
await this.authService.verifyChallengeResponse(
|
||||||
|
req.query?.token,
|
||||||
|
resource
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Challenge: ${challenge}, Resource: ${resource}, Response: ${req.query?.token}, isChallengeVerified: ${isChallengeVerified}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isChallengeVerified) {
|
||||||
|
this.rejectResponse(res, 'Invalid Challenge Response');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const isTokenVerified = await this.authService.verifyCaptchaToken(
|
||||||
|
req.query?.token,
|
||||||
|
req.headers['CF-Connecting-IP'] as string
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isTokenVerified) {
|
||||||
|
this.rejectResponse(res, 'Invalid Captcha Response');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async verifyUserFromRequest(req: Request): Promise<User> {
|
private async verifyUserFromRequest(req: Request): Promise<User> {
|
||||||
const token = req.headers.authorization;
|
const token = req.headers.authorization;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@ -311,6 +378,18 @@ export class NextAuthController {
|
|||||||
}
|
}
|
||||||
throw new BadRequestException(`User not found`);
|
throw new BadRequestException(`User not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rejectResponse(res: Response, error: string, status = 400) {
|
||||||
|
res.status(status);
|
||||||
|
res.json({
|
||||||
|
url: `https://${this.config.baseUrl}/api/auth/error?${new URLSearchParams(
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
).toString()}`,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkUrlOrigin = (url: string, origin: string) => {
|
const checkUrlOrigin = (url: string, origin: string) => {
|
||||||
|
@ -9,9 +9,11 @@ import {
|
|||||||
import { hash, verify } from '@node-rs/argon2';
|
import { hash, verify } from '@node-rs/argon2';
|
||||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
import { Config } from '../../config';
|
import { Config } from '../../config';
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
|
import { verifyChallengeResponse } from '../../storage';
|
||||||
import { MailService } from './mailer';
|
import { MailService } from './mailer';
|
||||||
|
|
||||||
export type UserClaim = Pick<
|
export type UserClaim = Pick<
|
||||||
@ -110,6 +112,38 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async verifyCaptchaToken(token: any, ip: string) {
|
||||||
|
if (typeof token !== 'string' || !token) return false;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('secret', this.config.auth.captcha.turnstile.secret);
|
||||||
|
formData.append('response', token);
|
||||||
|
formData.append('remoteip', ip);
|
||||||
|
// prevent replay attack
|
||||||
|
formData.append('idempotency_key', nanoid());
|
||||||
|
|
||||||
|
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
const result = await fetch(url, {
|
||||||
|
body: formData,
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const outcome = await result.json();
|
||||||
|
|
||||||
|
return (
|
||||||
|
!!outcome.success &&
|
||||||
|
// skip hostname check in dev mode
|
||||||
|
(this.config.affineEnv === 'dev' || outcome.hostname === this.config.host)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyChallengeResponse(response: any, resource: string) {
|
||||||
|
return verifyChallengeResponse(
|
||||||
|
response,
|
||||||
|
this.config.auth.captcha.challenge.bits,
|
||||||
|
resource
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async signIn(email: string, password: string): Promise<User> {
|
async signIn(email: string, password: string): Promise<User> {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
@ -34,3 +34,17 @@ export class StorageModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
||||||
|
|
||||||
|
export const verifyChallengeResponse = async (
|
||||||
|
response: any,
|
||||||
|
bits: number,
|
||||||
|
resource: string
|
||||||
|
) => {
|
||||||
|
if (typeof response !== 'string' || !response || !resource) return false;
|
||||||
|
return storageModule.verifyChallengeResponse(response, bits, resource);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mintChallengeResponse = async (resource: string, bits: number) => {
|
||||||
|
if (!resource) return null;
|
||||||
|
return storageModule.mintChallengeResponse(resource, bits);
|
||||||
|
};
|
||||||
|
@ -10,6 +10,7 @@ import { AuthModule } from '../src/modules/auth';
|
|||||||
import { AuthResolver } from '../src/modules/auth/resolver';
|
import { AuthResolver } from '../src/modules/auth/resolver';
|
||||||
import { AuthService } from '../src/modules/auth/service';
|
import { AuthService } from '../src/modules/auth/service';
|
||||||
import { PrismaModule } from '../src/prisma';
|
import { PrismaModule } from '../src/prisma';
|
||||||
|
import { mintChallengeResponse, verifyChallengeResponse } from '../src/storage';
|
||||||
import { RateLimiterModule } from '../src/throttler';
|
import { RateLimiterModule } from '../src/throttler';
|
||||||
|
|
||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
@ -176,3 +177,10 @@ test('should return valid sessionToken if request headers valid', async t => {
|
|||||||
);
|
);
|
||||||
t.is(result.sessionToken, '123456');
|
t.is(result.sessionToken, '123456');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('verify challenge', async t => {
|
||||||
|
const resource = 'xp8D3rcXV9bMhWrb6abxl';
|
||||||
|
const response = await mintChallengeResponse(resource, 20);
|
||||||
|
const success = await verifyChallengeResponse(response, 20, resource);
|
||||||
|
t.true(success);
|
||||||
|
});
|
||||||
|
@ -57,6 +57,7 @@ export default {
|
|||||||
process.env.SHOULD_REPORT_TRACE === 'true'
|
process.env.SHOULD_REPORT_TRACE === 'true'
|
||||||
)}`,
|
)}`,
|
||||||
'process.env.TRACE_REPORT_ENDPOINT': `"${process.env.TRACE_REPORT_ENDPOINT}"`,
|
'process.env.TRACE_REPORT_ENDPOINT': `"${process.env.TRACE_REPORT_ENDPOINT}"`,
|
||||||
|
'process.env.CAPTCHA_SITE_KEY': `"${process.env.CAPTCHA_SITE_KEY}"`,
|
||||||
runtimeConfig: getRuntimeConfig({
|
runtimeConfig: getRuntimeConfig({
|
||||||
distribution: 'browser',
|
distribution: 'browser',
|
||||||
mode: 'development',
|
mode: 'development',
|
||||||
|
1
packages/env/src/global.ts
vendored
1
packages/env/src/global.ts
vendored
@ -28,6 +28,7 @@ export const runtimeFlagsSchema = z.object({
|
|||||||
enableSQLiteProvider: z.boolean(),
|
enableSQLiteProvider: z.boolean(),
|
||||||
enableNotificationCenter: z.boolean(),
|
enableNotificationCenter: z.boolean(),
|
||||||
enableCloud: z.boolean(),
|
enableCloud: z.boolean(),
|
||||||
|
enableCaptcha: z.boolean(),
|
||||||
enableEnhanceShareMode: z.boolean(),
|
enableEnhanceShareMode: z.boolean(),
|
||||||
// this is for the electron app
|
// this is for the electron app
|
||||||
serverUrlPrefix: z.string(),
|
serverUrlPrefix: z.string(),
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
"jotai": "^2.4.3",
|
"jotai": "^2.4.3",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"swr": "2.2.0"
|
"swr": "2.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine/env": "workspace:*",
|
"@affine/env": "workspace:*",
|
||||||
|
@ -174,6 +174,7 @@ export type UIHandlers = {
|
|||||||
handleMaximizeApp: () => Promise<any>;
|
handleMaximizeApp: () => Promise<any>;
|
||||||
handleCloseApp: () => Promise<any>;
|
handleCloseApp: () => Promise<any>;
|
||||||
getGoogleOauthCode: () => Promise<any>;
|
getGoogleOauthCode: () => Promise<any>;
|
||||||
|
getChallengeResponse: (resource: string) => Promise<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClipboardHandlers = {
|
export type ClipboardHandlers = {
|
||||||
|
@ -21,8 +21,10 @@ napi-derive = "2"
|
|||||||
notify = { version = "6", features = ["serde"] }
|
notify = { version = "6", features = ["serde"] }
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
rand = "0.8"
|
||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
sha3 = "0.10"
|
||||||
sqlx = { version = "0.7.1", default-features = false, features = [
|
sqlx = { version = "0.7.1", default-features = false, features = [
|
||||||
"sqlite",
|
"sqlite",
|
||||||
"migrate",
|
"migrate",
|
||||||
|
9
packages/native/index.d.ts
vendored
9
packages/native/index.d.ts
vendored
@ -25,6 +25,15 @@ export enum ValidationResult {
|
|||||||
GeneralError = 3,
|
GeneralError = 3,
|
||||||
Valid = 4,
|
Valid = 4,
|
||||||
}
|
}
|
||||||
|
export function verifyChallengeResponse(
|
||||||
|
response: string,
|
||||||
|
bits: number,
|
||||||
|
resource: string
|
||||||
|
): Promise<boolean>;
|
||||||
|
export function mintChallengeResponse(
|
||||||
|
resource: string,
|
||||||
|
bits?: number | undefined | null
|
||||||
|
): Promise<string>;
|
||||||
export class SqliteConnection {
|
export class SqliteConnection {
|
||||||
constructor(path: string);
|
constructor(path: string);
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
|
@ -263,7 +263,14 @@ if (!nativeBinding) {
|
|||||||
throw new Error(`Failed to load native binding`);
|
throw new Error(`Failed to load native binding`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { SqliteConnection, ValidationResult } = nativeBinding;
|
const {
|
||||||
|
SqliteConnection,
|
||||||
|
ValidationResult,
|
||||||
|
verifyChallengeResponse,
|
||||||
|
mintChallengeResponse,
|
||||||
|
} = nativeBinding;
|
||||||
|
|
||||||
module.exports.SqliteConnection = SqliteConnection;
|
module.exports.SqliteConnection = SqliteConnection;
|
||||||
module.exports.ValidationResult = ValidationResult;
|
module.exports.ValidationResult = ValidationResult;
|
||||||
|
module.exports.verifyChallengeResponse = verifyChallengeResponse;
|
||||||
|
module.exports.mintChallengeResponse = mintChallengeResponse;
|
||||||
|
225
packages/native/src/hashcash.rs
Normal file
225
packages/native/src/hashcash.rs
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
use std::convert::TryFrom;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
|
||||||
|
use napi::{bindgen_prelude::AsyncTask, Env, JsBoolean, JsString, Result as NapiResult, Task};
|
||||||
|
use napi_derive::napi;
|
||||||
|
use rand::{
|
||||||
|
distributions::{Alphanumeric, Distribution},
|
||||||
|
thread_rng,
|
||||||
|
};
|
||||||
|
use sha3::{Digest, Sha3_256};
|
||||||
|
|
||||||
|
const SALT_LENGTH: usize = 16;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Stamp {
|
||||||
|
version: String,
|
||||||
|
claim: u32,
|
||||||
|
ts: String,
|
||||||
|
resource: String,
|
||||||
|
ext: String,
|
||||||
|
rand: String,
|
||||||
|
counter: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stamp {
|
||||||
|
fn check_expiration(&self) -> bool {
|
||||||
|
NaiveDateTime::parse_from_str(&self.ts, "%Y%m%d%H%M%S")
|
||||||
|
.ok()
|
||||||
|
.map(|ts| DateTime::<Utc>::from_naive_utc_and_offset(ts, Utc))
|
||||||
|
.and_then(|utc| {
|
||||||
|
utc
|
||||||
|
.checked_add_signed(Duration::minutes(5))
|
||||||
|
.map(|utc| Utc::now() <= utc)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check<S: AsRef<str>>(&self, bits: u32, resource: S) -> bool {
|
||||||
|
if self.version == "1"
|
||||||
|
&& bits <= self.claim
|
||||||
|
&& self.check_expiration()
|
||||||
|
&& self.resource == resource.as_ref()
|
||||||
|
{
|
||||||
|
let hex_digits = ((self.claim as f32) / 4.).floor() as usize;
|
||||||
|
|
||||||
|
// check challenge
|
||||||
|
let mut hasher = Sha3_256::new();
|
||||||
|
hasher.update(&self.format().as_bytes());
|
||||||
|
let result = format!("{:x}", hasher.finalize());
|
||||||
|
result[..hex_digits] == String::from_utf8(vec![b'0'; hex_digits]).unwrap()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}:{}:{}:{}:{}:{}:{}",
|
||||||
|
self.version, self.claim, self.ts, self.resource, self.ext, self.rand, self.counter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a new hashcash stamp.
|
||||||
|
pub fn mint(resource: String, bits: Option<u32>) -> Self {
|
||||||
|
let version = "1";
|
||||||
|
let now = Utc::now();
|
||||||
|
let ts = now.format("%Y%m%d%H%M%S");
|
||||||
|
let bits = bits.unwrap_or(20);
|
||||||
|
let rand = String::from_iter(
|
||||||
|
Alphanumeric
|
||||||
|
.sample_iter(thread_rng())
|
||||||
|
.take(SALT_LENGTH)
|
||||||
|
.map(char::from),
|
||||||
|
);
|
||||||
|
let challenge = format!("{}:{}:{}:{}:{}:{}", version, bits, ts, &resource, "", rand);
|
||||||
|
|
||||||
|
Stamp {
|
||||||
|
version: version.to_string(),
|
||||||
|
claim: bits,
|
||||||
|
ts: ts.to_string(),
|
||||||
|
resource,
|
||||||
|
ext: "".to_string(),
|
||||||
|
rand,
|
||||||
|
counter: {
|
||||||
|
let mut hasher = Sha3_256::new();
|
||||||
|
let mut counter = 0;
|
||||||
|
let hex_digits = ((bits as f32) / 4.).ceil() as usize;
|
||||||
|
let zeros = String::from_utf8(vec![b'0'; hex_digits]).unwrap();
|
||||||
|
loop {
|
||||||
|
hasher.update(&format!("{}:{:x}", challenge, counter).as_bytes());
|
||||||
|
let result = format!("{:x}", hasher.finalize_reset());
|
||||||
|
if result[..hex_digits] == zeros {
|
||||||
|
break format!("{:x}", counter);
|
||||||
|
};
|
||||||
|
counter += 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for Stamp {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
let stamp_vec = value.split(':').collect::<Vec<&str>>();
|
||||||
|
if stamp_vec.len() != 7 {
|
||||||
|
return Err(format!(
|
||||||
|
"Malformed stamp, expected 6 parts, got {}",
|
||||||
|
stamp_vec.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Stamp {
|
||||||
|
version: stamp_vec[0].to_string(),
|
||||||
|
claim: stamp_vec[1]
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "Malformed stamp".to_string())?,
|
||||||
|
ts: stamp_vec[2].to_string(),
|
||||||
|
resource: stamp_vec[3].to_string(),
|
||||||
|
ext: stamp_vec[4].to_string(),
|
||||||
|
rand: stamp_vec[5].to_string(),
|
||||||
|
counter: stamp_vec[6].to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AsyncVerifyChallengeResponse {
|
||||||
|
response: String,
|
||||||
|
bits: u32,
|
||||||
|
resource: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl Task for AsyncVerifyChallengeResponse {
|
||||||
|
type Output = bool;
|
||||||
|
type JsValue = JsBoolean;
|
||||||
|
|
||||||
|
fn compute(&mut self) -> NapiResult<Self::Output> {
|
||||||
|
Ok(if let Ok(stamp) = Stamp::try_from(self.response.as_str()) {
|
||||||
|
stamp.check(self.bits, &self.resource)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(&mut self, env: Env, output: bool) -> NapiResult<Self::JsValue> {
|
||||||
|
env.get_boolean(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn verify_challenge_response(
|
||||||
|
response: String,
|
||||||
|
bits: u32,
|
||||||
|
resource: String,
|
||||||
|
) -> AsyncTask<AsyncVerifyChallengeResponse> {
|
||||||
|
AsyncTask::new(AsyncVerifyChallengeResponse {
|
||||||
|
response,
|
||||||
|
bits,
|
||||||
|
resource,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AsyncMintChallengeResponse {
|
||||||
|
bits: Option<u32>,
|
||||||
|
resource: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
impl Task for AsyncMintChallengeResponse {
|
||||||
|
type Output = String;
|
||||||
|
type JsValue = JsString;
|
||||||
|
|
||||||
|
fn compute(&mut self) -> NapiResult<Self::Output> {
|
||||||
|
Ok(Stamp::mint(self.resource.clone(), self.bits).format())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(&mut self, env: Env, output: String) -> NapiResult<Self::JsValue> {
|
||||||
|
env.create_string(&output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[napi]
|
||||||
|
pub fn mint_challenge_response(
|
||||||
|
resource: String,
|
||||||
|
bits: Option<u32>,
|
||||||
|
) -> AsyncTask<AsyncMintChallengeResponse> {
|
||||||
|
AsyncTask::new(AsyncMintChallengeResponse { bits, resource })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::Stamp;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mint() {
|
||||||
|
let response = Stamp::mint("test".into(), Some(22)).format();
|
||||||
|
assert!(Stamp::try_from(response.as_str())
|
||||||
|
.unwrap()
|
||||||
|
.check(22, "test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_check() {
|
||||||
|
assert!(Stamp::try_from("1:20:20202116:test::Z4p8WaiO:31c14")
|
||||||
|
.unwrap()
|
||||||
|
.check(20, "test"));
|
||||||
|
assert!(!Stamp::try_from("1:20:20202116:test1::Z4p8WaiO:31c14")
|
||||||
|
.unwrap()
|
||||||
|
.check(20, "test"));
|
||||||
|
assert!(!Stamp::try_from("1:20:20202116:test::z4p8WaiO:31c14")
|
||||||
|
.unwrap()
|
||||||
|
.check(20, "test"));
|
||||||
|
assert!(!Stamp::try_from("1:20:20202116:test::Z4p8WaiO:31C14")
|
||||||
|
.unwrap()
|
||||||
|
.check(20, "test"));
|
||||||
|
assert!(Stamp::try_from("0:20:20202116:test::Z4p8WaiO:31c14").is_err());
|
||||||
|
assert!(!Stamp::try_from("1:19:20202116:test::Z4p8WaiO:31c14")
|
||||||
|
.unwrap()
|
||||||
|
.check(20, "test"));
|
||||||
|
assert!(!Stamp::try_from("1:20:20202115:test::Z4p8WaiO:31c14")
|
||||||
|
.unwrap()
|
||||||
|
.check(20, "test"));
|
||||||
|
}
|
||||||
|
}
|
@ -1 +1,2 @@
|
|||||||
pub mod sqlite;
|
pub mod sqlite;
|
||||||
|
pub mod hashcash;
|
@ -7,6 +7,7 @@ edition = "2021"
|
|||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = "0.4"
|
||||||
jwst-codec = { git = "https://github.com/toeverything/OctoBase.git", rev = "ad51b2c" }
|
jwst-codec = { git = "https://github.com/toeverything/OctoBase.git", rev = "ad51b2c" }
|
||||||
jwst-core = { git = "https://github.com/toeverything/OctoBase.git", rev = "ad51b2c" }
|
jwst-core = { git = "https://github.com/toeverything/OctoBase.git", rev = "ad51b2c" }
|
||||||
jwst-storage = { git = "https://github.com/toeverything/OctoBase.git", rev = "ad51b2c" }
|
jwst-storage = { git = "https://github.com/toeverything/OctoBase.git", rev = "ad51b2c" }
|
||||||
@ -15,6 +16,11 @@ napi = { version = "2", default-features = false, features = [
|
|||||||
"async",
|
"async",
|
||||||
] }
|
] }
|
||||||
napi-derive = { version = "2", features = ["type-def"] }
|
napi-derive = { version = "2", features = ["type-def"] }
|
||||||
|
rand = "0.8"
|
||||||
|
sha3 = "0.10"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = "1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
napi-build = "2"
|
napi-build = "2"
|
||||||
|
9
packages/storage/index.d.ts
vendored
9
packages/storage/index.d.ts
vendored
@ -3,6 +3,15 @@
|
|||||||
|
|
||||||
/* auto-generated by NAPI-RS */
|
/* auto-generated by NAPI-RS */
|
||||||
|
|
||||||
|
export function verifyChallengeResponse(
|
||||||
|
response: string,
|
||||||
|
bits: number,
|
||||||
|
resource: string
|
||||||
|
): Promise<boolean>;
|
||||||
|
export function mintChallengeResponse(
|
||||||
|
resource: string,
|
||||||
|
bits?: number | undefined | null
|
||||||
|
): Promise<string>;
|
||||||
export interface Blob {
|
export interface Blob {
|
||||||
contentType: string;
|
contentType: string;
|
||||||
lastModified: string;
|
lastModified: string;
|
||||||
|
@ -7,3 +7,5 @@ const binding = require('./storage.node');
|
|||||||
|
|
||||||
export const Storage = binding.Storage;
|
export const Storage = binding.Storage;
|
||||||
export const mergeUpdatesInApplyWay = binding.mergeUpdatesInApplyWay;
|
export const mergeUpdatesInApplyWay = binding.mergeUpdatesInApplyWay;
|
||||||
|
export const verifyChallengeResponse = binding.verifyChallengeResponse;
|
||||||
|
export const mintChallengeResponse = binding.mintChallengeResponse;
|
||||||
|
1
packages/storage/src/hashcash.rs
Symbolic link
1
packages/storage/src/hashcash.rs
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../../native/src/hashcash.rs
|
@ -1,5 +1,7 @@
|
|||||||
#![deny(clippy::all)]
|
#![deny(clippy::all)]
|
||||||
|
|
||||||
|
pub mod hashcash;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
fmt::{Debug, Display},
|
fmt::{Debug, Display},
|
||||||
|
@ -32,7 +32,7 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"swr": "2.2.0",
|
"swr": "2.2.4",
|
||||||
"valtio": "^1.11.2",
|
"valtio": "^1.11.2",
|
||||||
"y-protocols": "^1.0.6",
|
"y-protocols": "^1.0.6",
|
||||||
"y-provider": "workspace:*",
|
"y-provider": "workspace:*",
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"foxact": "^0.2.20",
|
"foxact": "^0.2.20",
|
||||||
"react-error-boundary": "^4.0.11",
|
"react-error-boundary": "^4.0.11",
|
||||||
"swr": "2.2.0"
|
"swr": "2.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine/plugin-cli": "workspace:*"
|
"@affine/plugin-cli": "workspace:*"
|
||||||
|
Loading…
Reference in New Issue
Block a user