login: new ip-change option
Massimo Melina committed
Sep 30, 2024 at 16:03 UTC
2e417876921ce009636834741da11fbbd5a229d9
10 files changed
+50
-23
frontend/src/components.ts
+13
-8
@@ -1,9 +1,11 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { Callback, getHFS, hfsEvent, hIcon, Html, isPrimitive, onlyTruthy, prefix } from './misc'
4
-import { ButtonHTMLAttributes, ChangeEvent, createElement as h, CSSProperties, forwardRef, Fragment,
4
+import {
5
+ ButtonHTMLAttributes, ChangeEvent, createElement as h, CSSProperties, forwardRef, Fragment,
6
HTMLAttributes, InputHTMLAttributes, isValidElement, MouseEventHandler, ReactNode, SelectHTMLAttributes,
6
- useMemo, useState, ComponentPropsWithoutRef } from 'react'
7
+ useMemo, useState, ComponentPropsWithoutRef, LabelHTMLAttributes
8
+} from 'react'
9
import _ from 'lodash'
10
import { t } from './i18n'
11
@@ -38,19 +40,22 @@ export const FlexV = forwardRef((props: FlexProps, ref) => h(Flex, { ref, vert:
40
41
interface CheckboxProps extends Omit<Partial<InputHTMLAttributes<any>>, 'onChange'> {
42
children?: ReactNode,
41
- value: any,
43
+ value?: any,
44
onChange?: (v: boolean, ev: ChangeEvent) => void
45
+ labelProps?: LabelHTMLAttributes<any>
46
}
44
-export function Checkbox({ onChange, value, children, ...props }: CheckboxProps) {
47
+
48
+export const Checkbox = forwardRef(({ onChange, value, children, labelProps, ...props }: CheckboxProps, ref) => {
49
const ret = h('input', {
50
+ ref,
51
type: 'checkbox',
47
- onChange: ev => onChange?.(Boolean(ev.target.checked), ev),
48
- checked: Boolean(value),
52
+ onChange: (ev: ChangeEvent<HTMLInputElement>) => onChange?.(Boolean(ev.target.checked), ev),
53
+ ...value !== undefined && { checked: Boolean(value) },
54
value: 1,
55
...props
56
})
52
- return !children ? ret : h('label', {}, ret, children)
53
-}
57
+ return !children ? ret : h('label', labelProps || {}, ret, children)
58
+})
59
60
interface SelectProps<T> extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> {
61
value: T, // just string for the time being
frontend/src/index.scss
+6
@@ -258,6 +258,12 @@ kbd {
258
input[type=checkbox] { margin-top: .3em; }
259
span:empty { display:none } /* avoid flex-gap */
260
}
261
+#login-options {
262
+ font-size: smaller;
263
+ input[type=checkbox] {
264
+ transform: scale(1.5);
265
+ }
266
+}
267
268
ul.dir {
269
padding: 0;
frontend/src/login.ts
+12
-5
@@ -5,16 +5,16 @@ import { state, useSnapState } from './state'
5
import { alertDialog, newDialog, toast } from './dialog'
6
import {
7
getHFS, hIcon, makeSessionRefresher, srpClientSequence, working, fallbackToBasicAuth,
8
- HTTP_CONFLICT, HTTP_UNAUTHORIZED,
8
+ HTTP_CONFLICT, HTTP_UNAUTHORIZED, CFG,
9
} from './misc'
10
import { createElement as h, Fragment, useEffect, useRef } from 'react'
11
import { t, useI18N } from './i18n'
12
import { reloadList } from './useFetchList'
13
-import { CustomCode } from './components'
13
+import { Checkbox, CustomCode } from './components'
14
15
-async function login(username:string, password:string) {
15
+async function login(username:string, password:string, extra?: object) {
16
const stopWorking = working()
17
- return srpClientSequence(username, password, apiCall).then(res => {
17
+ return srpClientSequence(username, password, apiCall, extra).then(res => {
18
stopWorking()
19
refreshSession(res)
20
state.loginRequired = false
@@ -63,6 +63,7 @@ export async function loginDialog(closable=true, reloadAfter=true) {
63
Content() {
64
const usrRef = useRef<HTMLInputElement>()
65
const pwdRef = useRef<HTMLInputElement>()
66
+ const ipRef = useRef<HTMLInputElement>()
67
useEffect(() => {
68
setTimeout(() => usrRef.current?.focus()) // setTimeout workarounds problem due to double-mount while in dev
69
}, [])
@@ -99,6 +100,10 @@ export async function loginDialog(closable=true, reloadAfter=true) {
100
),
101
h('div', { style: { textAlign: 'right' } },
102
h('button', { type: 'submit' }, t`Continue`)),
103
+ h('div', { id: 'login-options' },
104
+ h(Checkbox, { ref: ipRef },
105
+ t('allow_session_ip_change', "Allow IP change during this session")),
106
+ ),
107
)
108
109
function onKeyDown(ev: KeyboardEvent) {
@@ -116,7 +121,9 @@ export async function loginDialog(closable=true, reloadAfter=true) {
121
if (going || !usr || !pwd) return
122
going = true
123
try {
119
- const res = await login(usr, pwd)
124
+ const res = await login(usr, pwd, {
125
+ [CFG.allow_session_ip_change]: ipRef.current?.checked
126
+ })
127
await close(true)
128
toast(t`Logged in`, 'success')
129
if (res?.redirect)
src/auth.ts
+3
-1
@@ -3,7 +3,7 @@ import { HTTP_NOT_ACCEPTABLE, HTTP_SERVER_ERROR } from './cross-const'
3
import { SRPParameters, SRPRoutines, SRPServerSession } from 'tssrp6a'
4
import { Context } from 'koa'
5
import { srpClientPart } from './srp'
6
-import { DAY, getOrSet } from './cross'
6
+import { CFG, DAY, getOrSet } from './cross'
7
import { createHash } from 'node:crypto'
8
import events from './events'
9
@@ -52,6 +52,8 @@ export async function setLoggedIn(ctx: Context, username: string | false) {
52
if (!a) return
53
s.username = normalizeUsername(username)
54
s.ts = Date.now()
55
+ const k = CFG.allow_session_ip_change
56
+ s[k] = Boolean(ctx.state.params[k])
57
if (!a.expire && a.days_to_live)
58
updateAccount(a, { expire: new Date(Date.now() + a.days_to_live! * DAY) })
59
await events.emitAsync('login', ctx)
src/cross.ts
+2
-1
@@ -25,7 +25,8 @@ export const SORT_BY_OPTIONS = ['name', 'extension', 'size', 'time']
25
export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
26
export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url',
27
'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips',
28
- 'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads'])
28
+ 'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads',
29
+ 'allow_session_ip_change'])
30
export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
31
export type Dict<T=any> = Record<string, T>
32
export type Falsy = false | null | undefined | '' | 0
src/langs/hfs-lang-en.json
+2
-1
@@ -173,6 +173,7 @@
173
"Logged in": "Logged in",
174
"Logged out": "Logged out",
175
176
- "Cancel": "Cancel"
176
+ "Cancel": "Cancel",
177
+ "allow_session_ip_change": "Allow IP change during this session"
178
}
179
}
src/langs/hfs-lang-it.json
+2
-1
@@ -165,6 +165,7 @@
165
"Logged in": "Benvenuto",
166
"Logged out": "Arrivederci",
167
168
- "Cancel": "Annulla"
168
+ "Cancel": "Annulla",
169
+ "allow_session_ip_change": "Permetti cambio IP in questa sessione"
170
}
171
}
src/langs/hfs-lang-ru.json
+4
-1
@@ -174,6 +174,9 @@
174
"Show details": "Показать детали",
175
"upload_conflict": "уже существует",
176
"Logged in": "Вход выполнен",
177
- "Logged out": "Выход выполнен"
177
+ "Logged out": "Выход выполнен",
178
+
179
+ "Cancel": "Отмена",
180
+ "allow_session_ip_change": "Разрешить смену IP в этой сессии"
181
}
182
}
src/middlewares.ts
+4
-3
@@ -3,7 +3,7 @@
3
import compress from 'koa-compress'
4
import Koa from 'koa'
5
import { API_URI, DEV, HTTP_FOOL } from './const'
6
-import { DAY, dirTraversal, isLocalHost, splitAt, stream2string, tryJson } from './misc'
6
+import { CFG, DAY, dirTraversal, isLocalHost, splitAt, stream2string, tryJson } from './misc'
7
import { Readable } from 'stream'
8
import { applyBlock } from './block'
9
import { Account, accountCanLogin, getAccount } from './perm'
@@ -16,7 +16,7 @@ import session from 'koa-session'
16
import { app } from './index'
17
import events from './events'
18
19
-const allowSessionIpChange = defineConfig<boolean | 'https'>('allow_session_ip_change', false)
19
+const allowSessionIpChange = defineConfig<boolean | 'https'>(CFG.allow_session_ip_change, false)
20
const forceHttps = defineConfig('force_https', true)
21
const ignoreProxies = defineConfig('ignore_proxies', false)
22
const allowAuthorizationHeader = defineConfig('authorization_header', true)
@@ -52,7 +52,8 @@ export let cloudflareDetected: undefined | Date
52
export const someSecurity: Koa.Middleware = (ctx, next) => {
53
ctx.request.ip = normalizeIp(ctx.ip)
54
const ss = ctx.session
55
- if (ss?.username && (!allowSessionIpChange.get() || !ctx.secure && allowSessionIpChange.get() === 'https'))
55
+ const allowIpChange = ss?.[allowSessionIpChange.key()] ?? allowSessionIpChange.get() // session can override server setting
56
+ if (ss?.username && (!allowIpChange || !ctx.secure && allowIpChange === 'https'))
57
if (!ss.ip)
58
ss.ip = ctx.ip
59
else if (ss.ip !== ctx.ip) {
src/srp.ts
+2
-2
@@ -2,11 +2,11 @@
2
3
import { SRPClientSession, SRPParameters, SRPRoutines } from 'tssrp6a'
4
5
-export async function srpClientSequence(username:string, password:string, apiCall: (cmd:string, params:any) => any) {
5
+export async function srpClientSequence(username:string, password:string, apiCall: (cmd:string, params:any) => any, extra?: object) {
6
const { pubKey, salt } = await apiCall('loginSrp1', { username })
7
if (!salt) throw Error('salt')
8
const client = await srpClientPart(username, password, salt, pubKey)
9
- const res = await apiCall('loginSrp2', { pubKey: String(client.A), proof: String(client.M1) }) // bigint-s must be cast to string to be json-ed
9
+ const res = await apiCall('loginSrp2', { pubKey: String(client.A), proof: String(client.M1), ...extra }) // bigint-s must be cast to string to be json-ed
10
await client.step3(BigInt(res.proof)).catch(() => Promise.reject('trust'))
11
return res
12
}