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