@samitouri / QOSami-HFS / commits / 2e417876

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 }