Skip to content

Commit 4b33517

Browse files
committed
Merge branch 'password-validation-on-clone-creation' into 'master'
fix(ui): entropy password validation See merge request postgres-ai/database-lab!837
2 parents c8b1e82 + 976a89f commit 4b33517

File tree

5 files changed

+288
-10
lines changed

5 files changed

+288
-10
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { CloneDto, formatCloneDto } from '@postgres.ai/shared/types/api/entities/clone'
1+
import {
2+
CloneDto,
3+
formatCloneDto,
4+
} from '@postgres.ai/shared/types/api/entities/clone'
25

36
import { request } from 'helpers/request'
47

@@ -8,16 +11,18 @@ type Request = {
811
}
912

1013
export const getClone = async (req: Request) => {
11-
const response = await request('/rpc/dblab_clone_status', {
14+
const response = (await request('/rpc/dblab_clone_status', {
1215
method: 'POST',
1316
body: JSON.stringify({
1417
instance_id: req.instanceId,
1518
clone_id: req.cloneId,
16-
})
17-
})
19+
}),
20+
}))
1821

1922
return {
20-
response: response.ok ? formatCloneDto(await response.json() as CloneDto) : null,
23+
response: response.ok
24+
? formatCloneDto((await response.json()) as CloneDto)
25+
: null,
2126
error: response.ok ? null : response,
2227
}
2328
}

ui/packages/shared/components/ErrorStub/index.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import clsx from 'clsx'
1111

1212
type Props = {
1313
title?: string
14-
message?: string
14+
message?:
15+
| string
16+
| {
17+
details: string
18+
}
1519
className?: string
1620
size?: 'big' | 'normal'
1721
}
@@ -71,7 +75,9 @@ export const ErrorStub = (props: Props) => {
7175
)}
7276
>
7377
<h2 className={classes.title}>{title}</h2>
74-
<p className={classes.message}>{message}</p>
78+
<p className={classes.message}>
79+
{typeof message === 'object' ? message.details : message}
80+
</p>
7581
</Paper>
7682
)
7783
}
+232
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
const replaceChars = '!@$&*'
2+
const sepChars = '_-., '
3+
const otherSpecialChars = '“#%"()+/:;<=>?[\\]^{|}~'
4+
const lowerChars = 'abcdefghijklmnopqrstuvwxyz'
5+
const upperChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
6+
const digitsChars = '0123456789'
7+
export const MIN_ENTROPY = 60
8+
9+
function getBase(password: string): number {
10+
let uniqueChars: string[] = []
11+
for (const c of password) {
12+
if (!uniqueChars.includes(c)) {
13+
uniqueChars.push(c)
14+
}
15+
}
16+
let hasReplace = false
17+
let hasSep = false
18+
let hasOtherSpecial = false
19+
let hasLower = false
20+
let hasUpper = false
21+
let hasDigits = false
22+
let base = 0
23+
24+
for (let i = 0; i < uniqueChars.length; i++) {
25+
switch (true) {
26+
case replaceChars.includes(uniqueChars[i]):
27+
hasReplace = true
28+
break
29+
case sepChars.includes(uniqueChars[i]):
30+
hasSep = true
31+
break
32+
case otherSpecialChars.includes(uniqueChars[i]):
33+
hasOtherSpecial = true
34+
break
35+
case lowerChars.includes(uniqueChars[i]):
36+
hasLower = true
37+
break
38+
case upperChars.includes(uniqueChars[i]):
39+
hasUpper = true
40+
break
41+
case digitsChars.includes(uniqueChars[i]):
42+
hasDigits = true
43+
break
44+
default:
45+
base++
46+
break
47+
}
48+
}
49+
if (hasReplace) {
50+
base += replaceChars.length
51+
}
52+
if (hasSep) {
53+
base += sepChars.length
54+
}
55+
if (hasOtherSpecial) {
56+
base += otherSpecialChars.length
57+
}
58+
if (hasLower) {
59+
base += lowerChars.length
60+
}
61+
if (hasUpper) {
62+
base += upperChars.length
63+
}
64+
if (hasDigits) {
65+
base += digitsChars.length
66+
}
67+
return base
68+
}
69+
const seqNums = '0123456789'
70+
const seqKeyboard0 = 'qwertyuiop'
71+
const seqKeyboard1 = 'asdfghjkl'
72+
const seqKeyboard2 = 'zxcvbnm'
73+
const seqAlphabet = 'abcdefghijklmnopqrstuvwxyz'
74+
function removeMoreThanTwoFromSequence(s: string, seq: string): string {
75+
const seqRunes: string[] = Array.from(seq)
76+
let runes: string[] = Array.from(s)
77+
let matches = 0
78+
for (let i = 0; i < runes.length; i++) {
79+
for (let j = 0; j < seqRunes.length; j++) {
80+
if (i >= runes.length) {
81+
break
82+
}
83+
const r = runes[i]
84+
const r2 = seqRunes[j]
85+
if (r !== r2) {
86+
matches = 0
87+
continue
88+
}
89+
// found a match, advance the counter
90+
matches++
91+
if (matches > 2) {
92+
runes.splice(i, 1)
93+
} else {
94+
i++
95+
}
96+
}
97+
}
98+
return runes.join('')
99+
}
100+
function getReversedString(s: string): string {
101+
const rune: string[] = Array.from(s)
102+
const n = rune.length
103+
for (let i = 0; i < Math.floor(n / 2); i++) {
104+
;[rune[i], rune[n - 1 - i]] = [rune[n - 1 - i], rune[i]]
105+
}
106+
return rune.join('')
107+
}
108+
function removeMoreThanTwoRepeatingChars(s: string): string {
109+
let prevPrev: string = ''
110+
let prev: string = ''
111+
const runes: string[] = Array.from(s)
112+
for (let i = 0; i < runes.length; i++) {
113+
const r = runes[i]
114+
if (r === prev && r === prevPrev) {
115+
runes.splice(i, 1)
116+
i--
117+
}
118+
prevPrev = prev
119+
prev = r
120+
}
121+
return runes.join('')
122+
}
123+
function getLength(password: string): number {
124+
password = removeMoreThanTwoRepeatingChars(password)
125+
password = removeMoreThanTwoFromSequence(password, seqNums)
126+
password = removeMoreThanTwoFromSequence(password, seqKeyboard0)
127+
password = removeMoreThanTwoFromSequence(password, seqKeyboard1)
128+
password = removeMoreThanTwoFromSequence(password, seqKeyboard2)
129+
password = removeMoreThanTwoFromSequence(password, seqAlphabet)
130+
password = removeMoreThanTwoFromSequence(password, getReversedString(seqNums))
131+
password = removeMoreThanTwoFromSequence(
132+
password,
133+
getReversedString(seqKeyboard0),
134+
)
135+
password = removeMoreThanTwoFromSequence(
136+
password,
137+
getReversedString(seqKeyboard1),
138+
)
139+
password = removeMoreThanTwoFromSequence(
140+
password,
141+
getReversedString(seqKeyboard2),
142+
)
143+
password = removeMoreThanTwoFromSequence(
144+
password,
145+
getReversedString(seqAlphabet),
146+
)
147+
return password.length
148+
}
149+
export function getEntropy(password: string): number {
150+
return getEntropyInternal(password)
151+
}
152+
function getEntropyInternal(password: string): number {
153+
const base = getBase(password)
154+
const length = getLength(password)
155+
// calculate log2(base^length)
156+
return logPow(base, length, 2)
157+
}
158+
function logX(base: number, n: number): number {
159+
if (base == 0) {
160+
return 0
161+
} else {
162+
return Math.log2(n) / Math.log2(base)
163+
}
164+
}
165+
function logPow(expBase: number, pow: number, logBase: number): number {
166+
let total = 0
167+
for (let i = 0; i < pow; i++) {
168+
total += logX(logBase, expBase)
169+
}
170+
return total
171+
}
172+
173+
export function validatePassword(password: string, minEntropy: number): string {
174+
const entropy: number = getEntropy(password)
175+
if (entropy >= minEntropy) {
176+
return ''
177+
}
178+
179+
let hasReplace: boolean = false
180+
let hasSep: boolean = false
181+
let hasOtherSpecial: boolean = false
182+
let hasLower: boolean = false
183+
let hasUpper: boolean = false
184+
let hasDigits: boolean = false
185+
186+
for (const c of password) {
187+
switch (true) {
188+
case replaceChars.includes(c):
189+
hasReplace = true
190+
break
191+
case sepChars.includes(c):
192+
hasSep = true
193+
break
194+
case otherSpecialChars.includes(c):
195+
hasOtherSpecial = true
196+
break
197+
case lowerChars.includes(c):
198+
hasLower = true
199+
break
200+
case upperChars.includes(c):
201+
hasUpper = true
202+
break
203+
case digitsChars.includes(c):
204+
hasDigits = true
205+
break
206+
}
207+
}
208+
209+
const allMessages: string[] = []
210+
211+
if (!hasOtherSpecial || !hasSep || !hasReplace) {
212+
allMessages.push('including more special characters')
213+
}
214+
if (!hasLower) {
215+
allMessages.push('using lowercase letters')
216+
}
217+
if (!hasUpper) {
218+
allMessages.push('using uppercase letters')
219+
}
220+
if (!hasDigits) {
221+
allMessages.push('using numbers')
222+
}
223+
224+
if (allMessages.length > 0) {
225+
const errorMessage: string = `Weak password, try ${allMessages.join(
226+
', ',
227+
)} or using a longer password`
228+
return errorMessage
229+
}
230+
231+
return 'Weak password, try using a longer password'
232+
}

ui/packages/shared/pages/CreateClone/index.tsx

+34-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import cn from 'classnames'
12
import { useEffect } from 'react'
23
import { useHistory } from 'react-router-dom'
34
import { observer } from 'mobx-react-lite'
@@ -15,6 +16,11 @@ import { compareSnapshotsDesc } from '@postgres.ai/shared/utils/snapshot'
1516
import { round } from '@postgres.ai/shared/utils/numbers'
1617
import { formatBytesIEC } from '@postgres.ai/shared/utils/units'
1718
import { SectionTitle } from '@postgres.ai/shared/components/SectionTitle'
19+
import {
20+
MIN_ENTROPY,
21+
getEntropy,
22+
validatePassword,
23+
} from '@postgres.ai/shared/helpers/getEntropy'
1824

1925
import { useCreatedStores, MainStoreApi } from './useCreatedStores'
2026
import { useForm, FormValues } from './useForm'
@@ -37,15 +43,26 @@ type Props = Host
3743
export const CreateClone = observer((props: Props) => {
3844
const history = useHistory()
3945
const stores = useCreatedStores(props.api)
46+
const cloneError = stores.main.cloneError
4047
const timer = useTimer()
4148

4249
// Form.
4350
const onSubmit = async (values: FormValues) => {
51+
if (!values.dbPassword || getEntropy(values.dbPassword) < MIN_ENTROPY) {
52+
formik.setFieldError(
53+
'dbPassword',
54+
validatePassword(values.dbPassword, MIN_ENTROPY),
55+
)
56+
return
57+
}
58+
4459
timer.start()
4560

4661
const isSuccess = await stores.main.createClone(values)
4762

48-
if (!isSuccess) {
63+
formik.setFieldError('dbPassword', '')
64+
65+
if (!isSuccess || cloneError) {
4966
timer.pause()
5067
timer.reset()
5168
}
@@ -196,10 +213,23 @@ export const CreateClone = observer((props: Props) => {
196213
label="Database password *"
197214
type="password"
198215
value={formik.values.dbPassword}
199-
onChange={(e) => formik.setFieldValue('dbPassword', e.target.value)}
216+
onChange={(e) => {
217+
formik.setFieldValue('dbPassword', e.target.value)
218+
if (formik.errors.dbPassword) {
219+
formik.setFieldError('dbPassword', '')
220+
}
221+
}}
200222
error={Boolean(formik.errors.dbPassword)}
201223
disabled={isCreatingClone}
202224
/>
225+
<p
226+
className={cn(
227+
formik.errors.dbPassword && styles.error,
228+
styles.remark,
229+
)}
230+
>
231+
{formik.errors.dbPassword}
232+
</p>
203233
</div>
204234

205235
<div className={styles.section}>
@@ -220,7 +250,8 @@ export const CreateClone = observer((props: Props) => {
220250
<span>Expected cloning time:</span>
221251
<strong>
222252
{round(
223-
stores.main.instance.state?.cloning.expectedCloningTime as number,
253+
stores.main.instance.state?.cloning
254+
.expectedCloningTime as number,
224255
2,
225256
)}{' '}
226257
s

ui/packages/shared/pages/CreateClone/styles.module.scss

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
font-size: 12px;
2727
}
2828

29+
.error {
30+
color: red;
31+
}
32+
2933
.snapshotTag {
3034
font-weight: 700;
3135
margin-left: 4px;

0 commit comments

Comments
 (0)