admin protected by login
Massimo Melina committed
Mar 4, 2022 at 10:31 UTC
de22c6ced2fa438190bdabb7677ea3d2eee17894
30 files changed
+350
-72
admin/public/index.html
+1
@@ -11,6 +11,7 @@
11
/>
12
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
13
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
14
+ <script>SESSION = _HFS_SESSION_</script>
15
<title>HFS Admin</title>
16
</head>
17
<body>
admin/src/AccountsPage.ts
+8
-2
@@ -1,7 +1,7 @@
1
// This file is part of HFS - Copyright 2020-2021, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
import { isValidElement, createElement as h, useState, useEffect, Fragment } from "react"
4
-import { apiCall, useApiComp } from './api'
4
+import { apiCall, useApi, useApiComp } from './api'
5
import { Box, Button, Card, CardContent, Grid, List, ListItem, ListItemText, Typography } from '@mui/material'
6
import { Delete, Group, Person, PersonAdd, Refresh } from '@mui/icons-material'
7
import { BoolField, Form, MultiSelectField, SelectField, StringField } from './Form'
@@ -32,6 +32,7 @@ export default function AccountsPage() {
32
const [res, reload] = useApiComp('get_accounts')
33
const [sel, setSel] = useState<string[]>([])
34
const [add, setAdd] = useState(false)
35
+ const [config] = useApi('get_config', { only: ['admin_login'] }) // load values here and pass to AccountForm, to avoid unnecessary reloads
36
const styles = useStyles()
37
useEffect(() => { // if accounts are reloaded, review the selection to remove elements that don't exist anymore
38
if (isValidElement(res) || !Array.isArray(res?.list)) return
@@ -105,6 +106,7 @@ export default function AccountsPage() {
106
h(CardContent, {},
107
account ? h(AccountForm, {
108
account,
109
+ config,
110
groups: list.filter(x => !x.hasPassword).map( x => x.username ),
111
done(username) {
112
setAdd(false)
@@ -132,7 +134,7 @@ function hList(heading: string, list: any[]) {
134
)
135
}
136
135
-function AccountForm({ account, done, groups }: { account: Account, groups: string[], done: (username: string)=>void }) {
137
+function AccountForm({ account, done, groups, config }: { account: Account, groups: string[], done: (username: string)=>void, config: any }) {
138
const [values, setValues] = useState<Account & { password?: string, password2?: string }>(account)
139
const [belongsOptions, setBelongOptions] = useState<string[]>([])
140
useEffect(() => {
@@ -155,6 +157,10 @@ function AccountForm({ account, done, groups }: { account: Account, groups: stri
157
!group && { k: 'password2', comp: StringField, md: 6, type: 'password', autoComplete: 'off', label: 'Repeat password' },
158
{ k: 'ignore_limits', comp: BoolField,
159
helperText: values.ignore_limits ? "Speed limits don't apply to this account" : "Speed limits apply to this account" },
160
+ { k: 'admin', comp: BoolField, label: "Permission to access Admin interface",
161
+ helperText: "It's THIS interface you are using right now."
162
+ + (config.admin_login ? '' : " You are currently giving free access without login. You can force login in Configuration page.")
163
+ },
164
{ k: 'redirect', comp: StringField, helperText: "If you want this account to be redirected to a specific folder/address at login time" },
165
{ k: 'belongs', comp: MultiSelectField, label: "Inherits from", options: belongsOptions,
166
helperText: "Options and permissions of the selected groups will be applied to this account. "
admin/src/App.ts
+15
-2
@@ -7,17 +7,30 @@ import { Box, ThemeProvider, Typography } from '@mui/material'
7
import { Dialogs } from './dialog'
8
import logo from './logo.svg'
9
import { useMyTheme } from './theme'
10
+import { LoginRequired } from './LoginRequired'
11
12
function App() {
13
return h(ThemeProvider, { theme: useMyTheme() },
13
- h(BrowserRouter, {}, h(Routed)) )
14
+ h(ApplyTheme, {},
15
+ h(LoginRequired, {},
16
+ h(BrowserRouter, {}, h(Routed)) ) ) )
17
+}
18
+
19
+function ApplyTheme(props:any) {
20
+ return h(Box, {
21
+ sx: {
22
+ bgcolor:'background.default', color: 'text.primary',
23
+ position:'absolute', top:0, left:0, bottom:0, right:0,
24
+ },
25
+ ...props
26
+ })
27
}
28
29
function Routed() {
30
const loc = useLocation().pathname.slice(1)
31
const current = mainMenu.find(x => x.path === loc)
32
const title = current && (current.title || getMenuLabel(current))
20
- return h(Box, { display: 'flex', sx: { bgcolor:'background.default', color: 'text.primary' } },
33
+ return h(Box, { display: 'flex' },
34
h(MainMenu, { current }),
35
h(Box, {
36
component: 'main',
admin/src/ConfigPage.ts
+5
@@ -61,6 +61,11 @@ export default function ConfigPage() {
61
{ value: '0.0.0.0', label: 'any network' }
62
]
63
},
64
+ { k: 'admin_login', md: 6, comp: BoolField, label: 'Admin requires login',
65
+ disabled: !status?.any_admin_account,
66
+ helperText: (config.admin_network === '127.0.0.1' ? '' : "You should enable this because access is not restricted to localhost.")
67
+ + (status?.any_admin_account ? '' : " Before this, you must go to Accounts and give Admin access to some account.")
68
+ },
69
{ k: 'max_kbps', comp: NumberField, label: 'Max KB/s', helperText: "Limit output bandwidth" },
70
{ k: 'max_kbps_per_ip', comp: NumberField, label: 'Max KB/s per-ip' },
71
{ k: 'log', comp: StringField, label: 'Main log file' },
admin/src/HomePage.ts
+5
-1
@@ -6,13 +6,15 @@ import { useApi } from './api'
6
import { Dict, dontBotherWithKeys, InLink, objSameKeys, onlyTruthy, spinner } from './misc'
7
import { Launch } from '@mui/icons-material'
8
import md from './md'
9
+import { useSnapState } from './state'
10
11
interface ServerStatus { listening: boolean, port: number, error?: string, busy?: string }
12
13
export default function HomePage() {
14
+ const { username } = useSnapState()
15
const [status] = useApi<Dict<ServerStatus>>('get_status')
16
const [vfs] = useApi('get_vfs')
15
- const [cfg] = useApi('get_config', { only: ['https_port', 'cert', 'private_key'] })
17
+ const [cfg] = useApi('get_config', { only: ['https_port', 'cert', 'private_key', 'admin_network'] })
18
if (!status)
19
return spinner()
20
const { http, https } = status
@@ -25,6 +27,7 @@ export default function HomePage() {
27
const errors = errorMap && onlyTruthy(Object.entries(errorMap).map(([k,v]) =>
28
v && [md(`Protocol _${k}_ cannot work: `), v, typeof v === 'string' && /certificate|key/.test(v) && [' - ', cfgLink("provide adequate files")]]))
29
return h(Box, { display:'flex', gap: 2, flexDirection:'column' },
30
+ username && h(Alert, { severity: 'info' }, "Welcome "+username),
31
!cfg ? spinner() :
32
errors.length ? errors.map((msg, i) => h(Alert, { key: i, severity: 'error' }, dontBotherWithKeys(msg)))
33
: href && h(Alert, { severity: 'success' }, "Server is working"),
@@ -44,6 +47,7 @@ export default function HomePage() {
47
!errors.length && h(Fragment, {}, ' - ', cfgLink("switch http or https on"))
48
)
49
),
50
+ !username && cfg?.admin_network !== '127.0.0.1' && h(Alert, { severity: 'warning' }, "Admin interface is not limited to localhost - ", cfgLink("restrict access with login")),
51
52
vfs?.root && !vfs.root.children?.length && !vfs.root.source &&
53
h(Alert, { severity: 'warning' }, "You have no files shares - ", fsLink("add some files"))
admin/src/LoginRequired.ts
new
+89
@@ -0,0 +1,89 @@
1
+import { state, useSnapState } from './state'
2
+import { createElement as h, Fragment, useState } from 'react'
3
+import { Center } from './misc'
4
+import { Form } from './Form'
5
+import { apiCall } from './api'
6
+import { SRPClientSession, SRPParameters, SRPRoutines } from 'tssrp6a'
7
+import { LoadingButton } from '@mui/lab'
8
+import { Alert } from '@mui/material'
9
+
10
+export function LoginRequired({ children }: any) {
11
+ const { loginRequired } = useSnapState()
12
+ if (loginRequired)
13
+ return h(LoginForm)
14
+ return h(Fragment, {}, children)
15
+}
16
+
17
+function LoginForm() {
18
+ const [values, setValues] = useState({ username: '', password: '' })
19
+ const [loading, setLoading] = useState(false)
20
+ const [error, setError] = useState('')
21
+ return h(Center, { flexDirection: 'column', gap: 2 },
22
+ h(Form, {
23
+ values: {},
24
+ set(v, { k }) {
25
+ setValues({ ...values, [k]: v })
26
+ },
27
+ fields: [
28
+ { k: 'username' },
29
+ { k: 'password', type: 'password', autoComplete: 'current-password' },
30
+ ]
31
+ }),
32
+ h(LoadingButton, {
33
+ variant: 'contained',
34
+ loading,
35
+ async onClick() {
36
+ const { username, password } = values
37
+ if (!username || !password) return
38
+ setLoading(true)
39
+ try {
40
+ await login(username, password)
41
+ setError('')
42
+ state.loginRequired = false
43
+ state.username = username
44
+ }
45
+ catch(e) {
46
+ setError(String(e))
47
+ }
48
+ finally {
49
+ setLoading(false)
50
+ }
51
+ }
52
+ }, "Enter"),
53
+ h(Alert, { sx: { visibility: error ? '' : 'hidden' }, severity: 'error' }, error)
54
+ )
55
+}
56
+
57
+async function login(username: string, password: string) {
58
+ const WRONG = "Wrong username or password"
59
+ const { pubKey, salt } = await apiCall('loginSrp1', { username })
60
+ .catch(() => { throw WRONG })
61
+ if (!salt)
62
+ throw "Bad response from server"
63
+
64
+ const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
65
+ const srp = new SRPClientSession(srp6aNimbusRoutines);
66
+ const resStep1 = await srp.step1(username, password)
67
+ const resStep2 = await resStep1.step2(BigInt(salt), BigInt(pubKey))
68
+ const res = await apiCall('loginSrp2', { pubKey: String(resStep2.A), proof: String(resStep2.M1) }) // bigint-s must be cast to string to be json-ed
69
+ .catch(() => { throw WRONG })
70
+ await resStep2.step3(BigInt(res.proof))
71
+ .catch(() => { throw "Login aborted: server identity cannot be trusted" })
72
+
73
+ // login was successful, update state
74
+ sessionRefresher({ username, exp:res.exp })
75
+}
76
+
77
+// @ts-ignore
78
+sessionRefresher(window.SESSION)
79
+
80
+function sessionRefresher(response: any) {
81
+ if (!response) return
82
+ const { exp, username } = response
83
+ state.username = username
84
+ if (!username || !exp) return
85
+ const delta = new Date(exp).getTime() - Date.now()
86
+ const t = Math.min(delta - 30_000, 600_000)
87
+ console.debug('session refresh in', Math.round(t/1000))
88
+ setTimeout(() => apiCall('refresh_session').then(sessionRefresher), t)
89
+}
admin/src/LogoutPage.ts
new
+27
@@ -0,0 +1,27 @@
1
+import { createElement as h } from "react"
2
+import { Alert, Box, Button } from '@mui/material'
3
+import { apiCall, useApi } from './api'
4
+import { alertDialog } from "./dialog"
5
+import { useSnapState } from './state'
6
+
7
+export default function LogoutPage() {
8
+ const [cfg] = useApi('get_config', { only: [] })
9
+ const { username } = useSnapState()
10
+ if (!cfg) return null
11
+ if (!username)
12
+ return h(Alert, { severity: 'info' }, "You are not logged in, because authentication is not currently required. You can enable it in the Configuration page.")
13
+ return h(Box, { display: 'flex', flexDirection:'column', gap: 2 },
14
+ "You are logged in as " + username,
15
+ h(Box, {},
16
+ h(Button, {
17
+ size: 'large',
18
+ variant: 'contained',
19
+ onClick() {
20
+ apiCall('logout').then(() =>
21
+ apiCall('get_status').catch(()=>0), // second call is supposed to trigger a 401 if login is required
22
+ alertDialog) // show errors
23
+ }
24
+ }, "Yes, I want to logout")
25
+ )
26
+ )
27
+}
admin/src/MainMenu.ts
+3
-1
@@ -2,7 +2,7 @@
2
3
import { createElement as h, FunctionComponent } from 'react';
4
import { List, ListItemButton, ListItemIcon, ListItemText, Typography } from '@mui/material'
5
-import { AccountTree, ManageAccounts, Monitor, Public, Settings, SvgIconComponent } from '@mui/icons-material'
5
+import { AccountTree, Logout, ManageAccounts, Monitor, Public, Settings, SvgIconComponent } from '@mui/icons-material'
6
import _ from 'lodash'
7
import { Link } from 'react-router-dom'
8
import MonitorPage from './MonitorPage'
@@ -10,6 +10,7 @@ import ConfigPage from './ConfigPage';
10
import VfsPage from './VfsPage';
11
import AccountsPage from './AccountsPage';
12
import HomePage from './HomePage'
13
+import LogoutPage from './LogoutPage';
14
15
interface MenuEntry {
16
path: string
@@ -25,6 +26,7 @@ export const mainMenu: MenuEntry[] = [
26
{ path: 'configuration', icon: Settings, comp: ConfigPage },
27
{ path: 'fs', icon: AccountTree, label: 'File System', comp: VfsPage },
28
{ path: 'accounts', icon: ManageAccounts, comp: AccountsPage },
29
+ { path: 'Logout', icon: Logout, comp: LogoutPage }
30
]
31
32
interface MenuProps { current?:MenuEntry }
admin/src/api.ts
+3
@@ -4,6 +4,7 @@ import { createElement as h, ReactElement, useCallback, useEffect, useMemo, useR
4
import { Dict, Falsy, getCookie, spinner, useStateMounted } from './misc'
5
import { Alert } from '@mui/material'
6
import _ from 'lodash'
7
+import { state } from './state'
8
9
export function useApiComp<T=any>(...args: Parameters<typeof useApi>): [T | ReactElement, ()=>void] {
10
const [res, reload] = useApi<T>(...args)
@@ -30,6 +31,8 @@ export function apiCall(cmd: string, params?: Dict) : Promise<any> {
31
})
32
const msg = 'Failed API ' + cmd
33
console.warn(msg + (params ? ' ' + JSON.stringify(params) : ''))
34
+ if (res.status === 401)
35
+ state.loginRequired = true
36
throw new ApiError(res.status, msg)
37
}, err => {
38
throw err
admin/src/misc.ts
+6
-1
@@ -2,7 +2,7 @@
2
3
import { createElement as h, Fragment, ReactElement,
4
ReactNode, useCallback, useEffect, useRef, useState } from 'react'
5
-import { CircularProgress, IconButton, Link, Tooltip } from '@mui/material'
5
+import { Box, CircularProgress, IconButton, Link, Tooltip } from '@mui/material'
6
import { Link as RouterLink } from 'react-router-dom'
7
import { SxProps } from '@mui/system'
8
import { SvgIconComponent } from '@mui/icons-material'
@@ -131,3 +131,8 @@ export function dontBotherWithKeys(elements: ReactNode[]): (ReactNode|string)[]
131
export function InLink(props:any) {
132
return h(Link, { component: RouterLink, ...props })
133
}
134
+
135
+export function Center(props: any) {
136
+ return h(Box, { display:'flex', height:'100%', width:'100%', justifyContent:'center', alignItems:'center', ...props })
137
+}
138
+
admin/src/state.ts
+4
@@ -10,12 +10,16 @@ export const state = proxy<{
10
changes: Dict
11
vfs: VfsNode | undefined
12
selectedFiles: VfsNode[]
13
+ loginRequired: boolean
14
+ username: string
15
}>({
16
title: '',
17
config: {},
18
changes: {},
19
selectedFiles: [],
20
vfs: undefined,
21
+ loginRequired: false,
22
+ username: '',
23
})
24
25
export function useSnapState() {
dev-guidelines.md
+3
@@ -9,3 +9,6 @@
9
- it's easier to keep the frontend smaller for faster load
10
11
Of course this comes with a price to pay on the programmer's side, more work to do.
12
+
13
+# Syntax
14
+- For strings, I'm trying to use double-quotes or backticks for text that's read by the user, and single-quotes elsewhere.
fontello-config.json
+18
-6
@@ -102,12 +102,6 @@
102
"code": 61616,
103
"src": "fontawesome"
104
},
105
- {
106
- "uid": "b86df50a2d898bfcd371fa86c0b8b2fb",
107
- "css": "user-o",
108
- "code": 62144,
109
- "src": "fontawesome"
110
- },
105
{
106
"uid": "e99461abfef3923546da8d745372c995",
107
"css": "cog",
@@ -149,6 +143,24 @@
143
"css": "cancel",
144
"code": 59398,
145
"src": "fontawesome"
146
+ },
147
+ {
148
+ "uid": "8b80d36d4ef43889db10bc1f0dc9a862",
149
+ "css": "user",
150
+ "code": 59402,
151
+ "src": "fontawesome"
152
+ },
153
+ {
154
+ "uid": "09feb4465d9bd1364f4e301c9ddbaa92",
155
+ "css": "retweet",
156
+ "code": 59407,
157
+ "src": "fontawesome"
158
+ },
159
+ {
160
+ "uid": "186dec7a13156bbe2550790c158fb85d",
161
+ "css": "crown",
162
+ "code": 59460,
163
+ "src": "fontelico"
164
}
165
]
166
}
\ No newline at end of file
frontend/public/fontello.css
+3
-2
@@ -1,6 +1,6 @@
1
@font-face {
2
font-family: 'fontello';
3
- src: url('fontello.woff2?6810240') format('woff2');
3
+ src: url('fontello.woff2?13171865') format('woff2');
4
font-weight: normal;
5
font-style: normal;
6
}
@@ -10,7 +10,7 @@
10
@media screen and (-webkit-min-device-pixel-ratio:0) {
11
@font-face {
12
font-family: 'fontello';
13
- src: url('../font/fontello.svg?6810240#fontello') format('svg');
13
+ src: url('../font/fontello.svg?13171865#fontello') format('svg');
14
}
15
}
16
*/
@@ -67,6 +67,7 @@
67
.fa-search:before { content: '\e813'; } /* '' */
68
.fa-logout:before { content: '\e814'; } /* '' */
69
.fa-spin6:before { content: '\e839'; } /* '' */
70
+.fa-crown:before { content: '\e844'; } /* '' */
71
.fa-filter:before { content: '\f0b0'; } /* '' */
72
.fa-menu:before { content: '\f0c9'; } /* '' */
73
.fa-quote-left:before { content: '\f10d'; } /* '' */
frontend/public/fontello.woff2
Binary files a/frontend/public/fontello.woff2 and b/frontend/public/fontello.woff2 differ
frontend/src/UserPanel.ts
+14
-8
@@ -6,7 +6,7 @@ import { alertDialog, closeDialog, newDialog, promptDialog } from './dialog'
6
import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
7
import { apiCall } from './api'
8
import { logout } from './login'
9
-import { MenuButton } from './menu'
9
+import { MenuButton, MenuLink } from './menu'
10
11
export default function showUserPanel() {
12
newDialog({ Content })
@@ -15,17 +15,23 @@ export default function showUserPanel() {
15
function Content() {
16
const snap = useSnapState()
17
return h('div', { id: 'user-panel' },
18
- h('div', {}, 'User: ' + snap.username),
18
+ h('div', {}, "User: " + snap.username),
19
+ snap.admin_port && h(MenuLink, {
20
+ icon: 'admin',
21
+ label: "Admin interface",
22
+ href: 'http://' + window.location.hostname + ':' + snap.admin_port,
23
+ target: 'admin',
24
+ }),
25
h(MenuButton, {
26
icon: 'password',
21
- label: 'Change password',
27
+ label: "Change password",
28
async onClick() {
23
- const pwd = await promptDialog('Enter new password', { type: 'password' })
29
+ const pwd = await promptDialog("Enter new password", { type: 'password' })
30
if (!pwd) return
25
- const check = await promptDialog('RE-enter new password', { type: 'password' })
31
+ const check = await promptDialog("RE-enter new password", { type: 'password' })
32
if (!check) return
33
if (check !== pwd)
28
- return alertDialog('The second password you entered did not match the first. Procedure aborted.', 'warning')
34
+ return alertDialog("The second password you entered did not match the first. Procedure aborted.", 'warning')
35
const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
36
const res = await createVerifierAndSalt(srp6aNimbusRoutines, snap.username, pwd)
37
await apiCall('change_srp', { salt: String(res.s), verifier: String(res.v) }).catch(e => {
@@ -33,12 +39,12 @@ function Content() {
39
throw e
40
return apiCall('change_password', { newPassword: pwd }) // unencrypted version
41
})
36
- return alertDialog('Password changed')
42
+ return alertDialog("Password changed")
43
}
44
}),
45
h(MenuButton, {
46
icon: 'logout',
41
- label: 'Logout',
47
+ label: "Logout",
48
onClick() {
49
logout().then(closeDialog)
50
}
frontend/src/icons.ts
+1
@@ -21,6 +21,7 @@ const SYS_ICONS = {
21
password: 'key:🗝️',
22
download: ':📥',
23
invert: 'retweet:🙃',
24
+ admin: 'crown:👑',
25
}
26
27
document.fonts.ready.then(async ()=> {
frontend/src/index.scss
+3
@@ -213,6 +213,9 @@ ul.dir {
213
display:flex;
214
flex-direction: column;
215
gap: 1em;
216
+ & a>button {
217
+ width: 100%;
218
+ }
219
}
220
221
button label {
frontend/src/login.ts
+4
-4
@@ -26,13 +26,12 @@ export async function login(username:string, password:string) {
26
catch(e){
27
console.debug(String(e))
28
stopWorking()
29
- await alertDialog("Server identity cannot be trusted. Login aborted.", 'error')
29
+ await alertDialog("Login aborted: server identity cannot be trusted", 'error')
30
return
31
}
32
33
// login was successful, update state
34
- sessionRefresher({ username, exp:res.exp })
35
- state.username = username
34
+ sessionRefresher(res)
35
return res
36
}
37
catch(err) {
@@ -50,8 +49,9 @@ sessionRefresher(window.SESSION)
49
50
function sessionRefresher(response: any) {
51
if (!response) return
53
- const { exp, username } = response
52
+ const { exp, username, admin_port } = response
53
state.username = username
54
+ state.admin_port = admin_port
55
if (!username || !exp) return
56
const delta = new Date(exp).getTime() - Date.now()
57
const t = Math.min(delta - 30_000, 600_000)
frontend/src/menu.ts
+2
-1
@@ -125,10 +125,11 @@ export function MenuButton({ icon, label, toggled, onClick, className = '' }: Me
125
h('label', {}, label))
126
}
127
128
-export function MenuLink({ href, confirm, ...rest }: MenuButtonProps & { href: string, confirm?: string }) {
128
+export function MenuLink({ href, target, confirm, ...rest }: MenuButtonProps & { href: string, target?: string, confirm?: string }) {
129
return h('a', {
130
tabIndex: -1,
131
href,
132
+ target,
133
async onClick(ev) {
134
if (!confirm) return
135
ev.preventDefault()
frontend/src/state.ts
+1
@@ -23,6 +23,7 @@ export const state = proxy<{
23
invertOrder: boolean,
24
foldersFirst: boolean,
25
theme: string,
26
+ admin_port?: number,
27
}>({
28
iconsClass: '',
29
username: '',
src/adminApis.ts
+14
-1
@@ -1,6 +1,6 @@
1
// This file is part of HFS - Copyright 2020-2021, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { ApiHandlers } from './apis'
3
+import { ApiError, ApiHandlers } from './apis'
4
import { getConfig, getWholeConfig, setConfig } from './config'
5
import { getStatus } from './listen'
6
import { BUILD_TIMESTAMP, HFS_STARTED, VERSION } from './const'
@@ -10,6 +10,8 @@ import { Connection, getConnections } from './connections'
10
import { generatorAsCallback, onOffMap, pendingPromise } from './misc'
11
import _ from 'lodash'
12
import events from './events'
13
+import * as authApis from './api.auth'
14
+import { getAccounts } from './perm'
15
16
export const adminApis: ApiHandlers = {
17
@@ -34,6 +36,7 @@ export const adminApis: ApiHandlers = {
36
version: VERSION,
37
http: serverStatus(st.httpSrv, getConfig('port')),
38
https: serverStatus(st.httpsSrv, getConfig('https_port')),
39
+ any_admin_account: _.some(getAccounts(), a => a.admin || false),
40
}
41
42
function serverStatus(h: typeof st.httpSrv, configuredPort?: number) {
@@ -85,3 +88,13 @@ export const adminApis: ApiHandlers = {
88
}
89
90
}
91
+
92
+// protect most apis...
93
+for (const k in adminApis) {
94
+ const was = adminApis[k]
95
+ adminApis[k] = (params, ctx) =>
96
+ getConfig('admin_login') && !ctx.state.accountIsAdmin ? new ApiError(401)
97
+ : was(params, ctx)
98
+}
99
+// exception made for auth apis
100
+Object.assign(adminApis, authApis)
src/api.accounts.ts
+24
-2
@@ -2,7 +2,18 @@
2
3
import { changePasswordHelper, changeSrpHelper } from './api.helpers'
4
import { ApiError, ApiHandlers } from './apis'
5
-import { addAccount, delAccount, getAccount, getAccounts, setAccount } from './perm'
5
+import {
6
+ accountCanLogin,
7
+ accountHasPassword,
8
+ addAccount,
9
+ delAccount,
10
+ getAccount,
11
+ getAccounts,
12
+ getFromAccount,
13
+ setAccount
14
+} from './perm'
15
+import _ from 'lodash'
16
+import { getConfig } from './config'
17
18
const apis: ApiHandlers = {
19
@@ -15,7 +26,7 @@ const apis: ApiHandlers = {
26
list: Object.values(getAccounts()).map(ac => ({
27
...ac,
28
username: ac.username, // it's hidden and won't be copied by the spread operator
18
- hasPassword: Boolean(ac.password || ac.hashed_password || ac.srp),
29
+ hasPassword: accountHasPassword(ac),
30
password: undefined,
31
hashed_password: undefined,
32
srp: undefined,
@@ -24,7 +35,18 @@ const apis: ApiHandlers = {
35
},
36
37
set_account({ username, changes }) {
38
+ const { admin } = changes
39
+ if (typeof admin !== 'boolean' && typeof admin !== 'undefined')
40
+ return new ApiError(400, "admin must be boolean")
41
+ if (getConfig('admin_login') && admin === false && !anyOtherAccessibleAccountWithAdmin())
42
+ return new ApiError(403, "you can't disable admin because this is the last account with such permission")
43
return setAccount(username, changes) ? {} : new ApiError(400)
44
+
45
+ function anyOtherAccessibleAccountWithAdmin() {
46
+ return _.some(getAccounts(), a => accountCanLogin(a)
47
+ // with undefined we invite search to continue to its groups, because disabling admin on an account will still leave its inheritance on
48
+ && Boolean(getFromAccount(a, a => a.username === username ? undefined : a.admin)))
49
+ }
50
},
51
52
add_account({ username, ...rest }) {
src/api.auth.ts
+10
-5
@@ -1,6 +1,6 @@
1
// This file is part of HFS - Copyright 2020-2021, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { getAccount, getCurrentUsername } from './perm'
3
+import { getAccount, getCurrentUsername, getFromAccount } from './perm'
4
import { verifyPassword } from './crypt'
5
import { ApiError, ApiHandler } from './apis'
6
import { SRPParameters, SRPRoutines, SRPServerSession, SRPServerSessionStep1 } from 'tssrp6a'
@@ -8,6 +8,7 @@ import { SESSION_DURATION } from './const'
8
import { randomId } from './misc'
9
import Koa from 'koa'
10
import { changeSrpHelper, changePasswordHelper } from './api.helpers'
11
+import { getListeningAdminPort } from './listen'
12
13
const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
14
const srpSession = new SRPServerSession(srp6aNimbusRoutines)
@@ -61,6 +62,8 @@ export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
62
return new ApiError(401)
63
if (!account.srp)
64
return new ApiError(406) // unacceptable
65
+ if (ctx.state.admin && !getFromAccount(account, a => a.admin))
66
+ return new ApiError(403)
67
68
const [salt, verifier] = account.srp.split('|')
69
const step1 = await srpSession.step1(account.username, BigInt(salt), BigInt(verifier))
@@ -81,7 +84,7 @@ export const loginSrp2: ApiHandler = async ({ pubKey, proof }, ctx) => {
84
const M2 = await step1.step2(BigInt(pubKey), BigInt(proof))
85
loggedIn(ctx, username)
86
const acc = getAccount(username)
84
- return { proof: String(M2), redirect: acc?.redirect, ...makeExp() }
87
+ return { proof: String(M2), redirect: acc?.redirect, ...await refresh_session({},ctx) }
88
}
89
catch(e) {
90
return new ApiError(401, String(e))
@@ -100,9 +103,11 @@ export const logout: ApiHandler = async ({}, ctx) => {
103
}
104
105
export const refresh_session: ApiHandler = async ({}, ctx) => {
103
- if (!ctx.session)
104
- return new ApiError(500)
105
- return { username: getCurrentUsername(ctx), ...makeExp() }
106
+ return !ctx.session ? new ApiError(500) : {
107
+ username: getCurrentUsername(ctx),
108
+ ...makeExp(),
109
+ admin_port: ctx.state.admin ? undefined : getListeningAdminPort()
110
+ }
111
}
112
113
export const change_password: ApiHandler = async ({ newPassword }, ctx) => {
src/index.ts
+19
-22
@@ -8,8 +8,7 @@ import { frontEndApis } from './frontEndApis'
8
import { log } from './log'
9
import { pluginsMiddleware } from './plugins'
10
import { throttler } from './throttler'
11
-import { getAccount, getCurrentUsername, getCurrentUsernameExpanded } from './perm'
12
-import { headRequests, gzipper, sessions, frontendAndSharedFiles, someSecurity } from './middlewares'
11
+import { headRequests, gzipper, sessions, frontendAndSharedFiles, someSecurity, prepareState } from './middlewares'
12
import './listen'
13
import { serveAdminFiles } from './serveFrontend'
14
import { adminApis } from './adminApis'
@@ -20,30 +19,28 @@ console.log('version', VERSION||'-')
19
console.log('build', BUILD_TIMESTAMP||'-')
20
console.debug('cwd', process.cwd())
21
23
-export const adminApp = new Koa()
24
- .use(someSecurity)
22
+const keys = ['hfs-keys-test']
23
+
24
+export const adminApp = new Koa({ keys })
25
+adminApp.use(someSecurity)
26
+ .use(sessions(adminApp))
27
+ .use(prepareState(true))
28
.use(mount(API_URI, apiMiddleware(adminApis)))
29
.use(serveAdminFiles)
30
.on('error', errorHandler)
31
29
-export const app = new Koa({ keys: ['hfs-keys-test'] })
30
-app.use(someSecurity)
31
-app.use(sessions(app))
32
-app.use(async (ctx, next) => {
33
- ctx.state.usernames = getCurrentUsernameExpanded(ctx) // accounts chained via .belongs for permissions check
34
- ctx.state.account = getAccount(getCurrentUsername(ctx))
35
- await next()
36
-})
37
-app.use(headRequests)
38
-app.use(log())
39
-app.use(pluginsMiddleware())
40
-app.use(throttler())
41
-app.use(gzipper)
42
-
43
-// serve apis
44
-app.use(mount(API_URI, apiMiddleware(frontEndApis)))
45
-app.use(frontendAndSharedFiles)
46
-app.on('error', errorHandler)
32
+export const frontendApp = new Koa({ keys })
33
+frontendApp.use(someSecurity)
34
+ .use(sessions(frontendApp))
35
+ .use(prepareState())
36
+ .use(headRequests)
37
+ .use(log())
38
+ .use(pluginsMiddleware())
39
+ .use(throttler())
40
+ .use(gzipper)
41
+ .use(mount(API_URI, apiMiddleware(frontEndApis)))
42
+ .use(frontendAndSharedFiles)
43
+ .on('error', errorHandler)
44
45
function errorHandler(err:Error & { code:string, path:string }) {
46
const { code } = err
src/listen.ts
+6
-3
@@ -2,7 +2,7 @@
2
3
import * as http from 'http'
4
import { defineConfig, getConfig, subscribeConfig } from './config'
5
-import { adminApp, app } from './index'
5
+import { adminApp, frontendApp } from './index'
6
import * as https from 'https'
7
import { watchLoad } from './watchLoad'
8
import { networkInterfaces } from 'os';
@@ -20,7 +20,7 @@ let adminSrv: http.Server & ServerExtra
20
21
subscribeConfig<number>({ k:'port', defaultValue: 80 }, async port => {
22
await stopServer(httpSrv)
23
- httpSrv = http.createServer(app.callback())
23
+ httpSrv = http.createServer(frontendApp.callback())
24
port = await startServer(httpSrv, { port, name:'http' })
25
if (!port) return
26
httpSrv.on('connection', newConnection)
@@ -78,7 +78,7 @@ async function considerHttps() {
78
await stopServer(httpsSrv)
79
let port = getConfig('https_port')
80
try {
81
- httpsSrv = https.createServer({ key: httpsNeeds.private_key, cert: httpsNeeds.cert }, app.callback())
81
+ httpsSrv = https.createServer({ key: httpsNeeds.private_key, cert: httpsNeeds.cert }, frontendApp.callback())
82
const missingKey = _.findKey(httpsNeeds, v => !v) as keyof typeof httpsNeeds
83
httpsSrv.error = port < 0 ? undefined
84
: missingKey && prefix(getConfig(missingKey) ? "cannot read file for " : "missing ", httpsNeedsNames[missingKey])
@@ -175,3 +175,6 @@ function printUrls(port: number, proto: string) {
175
}
176
}
177
178
+export function getListeningAdminPort() {
179
+ return (adminSrv.address() as any)?.port as number | undefined
180
+}
src/middlewares.ts
+11
@@ -13,6 +13,7 @@ import { serveFileNode } from './serveFile'
13
import { serveFrontend } from './serveFrontend'
14
import mount from 'koa-mount'
15
import { Readable } from 'stream'
16
+import { getAccount, getCurrentUsername, getCurrentUsernameExpanded } from './perm'
17
18
export const gzipper = compress({
19
threshold: 2048,
@@ -91,3 +92,13 @@ export const someSecurity: Koa.Middleware = async (ctx, next) => {
92
return next()
93
}
94
95
+export function prepareState(admin=false): Koa.Middleware {
96
+ return async (ctx, next) => {
97
+ ctx.state.usernames = getCurrentUsernameExpanded(ctx) // accounts chained via .belongs for permissions check
98
+ ctx.state.account = getAccount(getCurrentUsername(ctx))
99
+ ctx.state.admin = admin
100
+ if (admin)
101
+ ctx.state.accountIsAdmin = ctx.state.usernames.some((u:string) => getAccount(u)?.admin)
102
+ await next()
103
+ }
104
+}
src/perm.ts
+27
-1
@@ -19,6 +19,7 @@ export interface Account {
19
srp?: string
20
belongs?: string[]
21
ignore_limits?: boolean
22
+ admin?: boolean
23
redirect?: string
24
}
25
interface Accounts { [username:string]: Account }
@@ -146,7 +147,7 @@ export function renameAccount(from: string, to: string) {
147
}
148
}
149
149
-const assignableProps = ['redirect','ignore_limits','belongs']
150
+const assignableProps = ['redirect','ignore_limits','belongs','admin']
151
152
export function addAccount(username: string, props: Partial<Account>) {
153
if (!username || accounts[username])
@@ -154,6 +155,7 @@ export function addAccount(username: string, props: Partial<Account>) {
155
const copy = { username, ..._.pick(props, assignableProps) }
156
setHidden(copy, { username })
157
accounts[username] = copy
158
+ saveAccountsAsap()
159
return copy
160
}
161
@@ -162,6 +164,7 @@ export function setAccount(username: string, changes: Partial<Account>) {
164
if (newU)
165
renameAccount(username, newU)
166
Object.assign(getAccount(newU || username), _.pick(rest, assignableProps))
167
+ saveAccountsAsap()
168
return true
169
}
170
@@ -169,5 +172,28 @@ export function delAccount(username: string) {
172
if (!getAccount(username))
173
return false
174
delete accounts[username]
175
+ saveAccountsAsap()
176
return true
177
}
178
+
179
+// get some property from account, searching in its groups if necessary. Search is breadth-first, and this determines priority of inheritance.
180
+export function getFromAccount<T=any>(account: Account | string, getter:(a:Account) => T) {
181
+ const search = [account]
182
+ for (const accountOrUsername of search) {
183
+ const a = typeof accountOrUsername === 'string' ? getAccount(accountOrUsername) : accountOrUsername
184
+ if (!a) continue
185
+ const res = getter(a)
186
+ if (res !== undefined)
187
+ return res
188
+ if (a.belongs)
189
+ search.push(...a.belongs)
190
+ }
191
+}
192
+
193
+export function accountHasPassword(account: Account) {
194
+ return Boolean(account.password || account.hashed_password || account.srp)
195
+}
196
+
197
+export function accountCanLogin(account: Account) {
198
+ return accountHasPassword(account)
199
+}
src/serveFrontend.ts
+20
-9
@@ -48,35 +48,46 @@ const serveStaticFrontend : Koa.Middleware = async (ctx, next) => {
48
: undefined
49
return serveFile(fullPath, 'auto', modifier)(ctx, next)
50
}
51
+ await serveIndex(ctx, fullPath)
52
+ await next()
53
+}
54
+
55
+async function serveIndex(ctx: Koa.Context, fullPath: string) {
56
// we don't cache the index as it's small and may prevent plugins change to apply
57
ctx.body = await treatIndex(ctx, String(await fs.readFile(fullPath)))
58
ctx.type = 'html'
59
ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate')
55
- await next()
60
}
61
58
-function serveAdminProxy(port?: string) { // used for development
62
+function serveProxyAdmin(port?: string) { // used for development
63
if (!port)
64
return
65
console.debug('admin: proxied')
66
let proxy: Koa.Middleware
67
import('koa-better-http-proxy').then(lib =>
64
- proxy = lib.default('localhost:'+port, {}) )
68
+ proxy = lib.default('localhost:'+port, {
69
+ userResDecorator(res, data, ctx) {
70
+ return !ctx.path.includes('.') ? treatIndex(ctx, data.toString('utf8'))
71
+ : data
72
+ }
73
+ }) )
74
return function() { //@ts-ignore
75
return proxy.apply(this,arguments)
76
}
77
}
78
70
-const serveAdminStatic : Koa.Middleware = async (ctx, next) => {
71
- const fullPath = path.join(__dirname, '..', DEV_STATIC, 'admin', ctx.path.includes('.') ? ctx.path : '/index.html')
72
- return serveFile(fullPath, 'auto')(ctx, next)
79
+const serveStaticAdmin : Koa.Middleware = async (ctx, next) => {
80
+ const index = !ctx.path.includes('.')
81
+ const fullPath = path.join(__dirname, '..', DEV_STATIC, 'admin', index ? '/index.html' : ctx.path)
82
+ return index ? await serveIndex(ctx, fullPath)
83
+ : serveFile(fullPath, 'auto')(ctx, next)
84
}
85
86
async function treatIndex(ctx: Koa.Context, body: string) {
87
const session = await refresh_session({}, ctx)
88
ctx.set('etag', '')
89
return body
79
- .replace(/((?:src|href) *= *['"])\/?(?![a-z]+:\/\/)/g, '$1' + FRONTEND_URI)
90
+ .replace(ctx.state.admin ? /^NEVER$/ : /((?:src|href) *= *['"])\/?(?![a-z]+:\/\/)/g, '$1' + FRONTEND_URI)
91
.replace('_HFS_SESSION_', session instanceof ApiError ? 'null' : JSON.stringify(session))
92
// replacing this text allow us to avoid injecting in frontends that don't support plugins. Don't use a <--comment--> or it will be removed by webpack
93
.replace('_HFS_PLUGINS_', pluginsInjection)
@@ -91,8 +102,8 @@ function pluginsInjection() {
102
+ js.map(uri => `\n<script defer src='${uri}'></script>`).join('')
103
}
104
94
-export const serveAdminFiles = serveAdminProxy(process.env.ADMIN_PROXY)
95
- || serveAdminStatic
105
+export const serveAdminFiles = serveProxyAdmin(process.env.ADMIN_PROXY)
106
+ || serveStaticAdmin
107
108
export const serveFrontend = serveProxyFrontend(process.env.FRONTEND_PROXY)
109
|| serveStaticFrontend
todo.md
+4
-1
@@ -5,14 +5,17 @@ aggiornare admin gui
5
counters: non contare richieste fallite
6
consider having mime as ext,ext instead of *.ext|*.ext
7
# To do
8
+- monorepo + share code between apps
9
+- admin/accounts: show icon for accounts with (possibly inherited) admin access
10
+- expose admin at same port of frontend
11
- admin: improve masks editor
12
- if specified config is a folder, check for file config.yaml inside
13
- merge accounts in config
14
- frontend: ok button to inputDialogs
15
- admin: in a group, show linked accounts
16
+- admin/monitor: show file currently downloaded
17
- admin/config: use filepicker for https files
18
- admin: warn in case of items with same name
15
-- password protect admin
19
- allowed referer
20
- admin/plugins
21
- download-counter: expose results on admin