@samitouri / QOSami-HFS / commits / d68e483d

admin/accounts: new "enabled" flag

Massimo Melina committed Sep 25, 2023 at 15:22 UTC d68e483d3619e2493cab06dc2dc791485610d5a6
8 files changed +59 -30
admin/src/AccountForm.ts
+3 -2
@@ -53,9 +53,10 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
53 },
54 !group && { k: 'password2', md: 6, xl: 4, type: 'password', autoComplete: 'new-password', label: 'Repeat password',
55 getError: (x, { values }) => (x||'') !== (values.password||'') && "Enter same password" },
56 - { k: 'ignore_limits', comp: BoolField, xl: 6,
56 + { k: 'disabled', comp: BoolField, fromField: x=>!x, toField: x=>!x, label: "Enabled", xs: 5, md: 6, xl: 3 },
57 + { k: 'ignore_limits', comp: BoolField, xs: 7, md: 6, xl: 4,
58 helperText: values.ignore_limits ? "Speed limits don't apply to this account" : "Speed limits apply to this account" },
58 - { k: 'admin', comp: BoolField, xl: 6, fromField: (v:boolean) => v||null, label: "Permission to access Admin-panel",
59 + { k: 'admin', comp: BoolField, xs: true, fromField: (v:boolean) => v||null, label: "Permission to access Admin-panel",
60 helperText: "To access THIS interface you are using right now",
61 ...!account.admin && account.adminActualAccess && { value: true, helperText: "This permission is inherited" },
62 },
admin/src/AccountsPage.ts
+6 -2
@@ -3,7 +3,7 @@
3 import { createElement as h, useState, useEffect, Fragment } from "react"
4 import { apiCall, useApiEx } from './api'
5 import { Alert, Box, Button, Card, CardContent, Grid, List, ListItem, ListItemText, Typography } from '@mui/material'
6 -import { Close, Delete, Group, MilitaryTech, Person, PersonAdd } from '@mui/icons-material'
6 +import { Close, Delete, DoNotDisturb, Group, MilitaryTech, Person, PersonAdd } from '@mui/icons-material'
7 import { IconBtn, iconTooltip, newDialog, reloadBtn, useBreakpoint } from './misc'
8 import { TreeItem, TreeView } from '@mui/lab'
9 import MenuButton from './MenuButton'
@@ -21,6 +21,7 @@ export interface Account {
21 admin?: boolean
22 adminActualAccess?: boolean
23 ignore_limits?: boolean
24 + disabled?: boolean
25 redirect?: string
26 belongs?: string[]
27 }
@@ -166,5 +167,8 @@ export default function AccountsPage() {
167 }
168
169 export function account2icon(ac: Account, props={}) {
169 - return h(ac.hasPassword ? Person : Group, props)
170 + return h(Fragment, {},
171 + h(ac.hasPassword ? Person : Group, props),
172 + ac.disabled && h(DoNotDisturb),
173 + )
174 }
src/api.auth.ts
+7 -7
@@ -1,6 +1,6 @@
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 { Account, getAccount, getCurrentUsername, normalizeUsername } from './perm'
3 +import { Account, accountCanLogin, getAccount, getCurrentUsername, normalizeUsername } from './perm'
4 import { verifyPassword } from './crypt'
5 import { ApiError, ApiHandler } from './apiMiddleware'
6 import { SRPParameters, SRPRoutines, SRPServerSession, SRPServerSessionStep1 } from 'tssrp6a'
@@ -39,17 +39,17 @@ function makeExp() {
39 export const login: ApiHandler = async ({ username, password }, ctx) => {
40 if (!username || !password) // some validation
41 return new ApiError(HTTP_BAD_REQUEST)
42 - const acc = getAccount(username)
43 - if (!acc)
42 + const account = getAccount(username)
43 + if (!account || !accountCanLogin(account))
44 return new ApiError(HTTP_UNAUTHORIZED)
45 - if (!acc.hashed_password)
45 + if (!account.hashed_password)
46 return new ApiError(HTTP_NOT_ACCEPTABLE)
47 - if (!await verifyPassword(acc.hashed_password, password))
47 + if (!await verifyPassword(account.hashed_password, password))
48 return new ApiError(HTTP_UNAUTHORIZED)
49 if (!ctx.session)
50 return new ApiError(HTTP_SERVER_ERROR)
51 await loggedIn(ctx, username)
52 - return { ...makeExp(), redirect: acc.redirect }
52 + return { ...makeExp(), redirect: account.redirect }
53 }
54
55 export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
@@ -58,7 +58,7 @@ export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
58 const account = getAccount(username)
59 if (!ctx.session)
60 return new ApiError(HTTP_SERVER_ERROR)
61 - if (!account) // TODO simulate fake account to prevent knowing valid usernames
61 + if (!account || !accountCanLogin(account)) // TODO simulate fake account to prevent knowing valid usernames
62 return new ApiError(HTTP_UNAUTHORIZED)
63 try {
64 const { step1, ...rest } = await srpStep1(account)
src/middlewares.ts
+4 -2
@@ -23,7 +23,7 @@ import { serveGuiFiles } from './serveGuiFiles'
23 import mount from 'koa-mount'
24 import { Readable } from 'stream'
25 import { applyBlock } from './block'
26 -import { getAccount } from './perm'
26 +import { accountCanLogin, getAccount } from './perm'
27 import { socket2connection, updateConnection, normalizeIp } from './connections'
28 import basicAuth from 'basic-auth'
29 import { SRPClientSession, SRPParameters, SRPRoutines } from 'tssrp6a'
@@ -234,7 +234,9 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
234 if (ctx.session)
235 ctx.session.maxAge = sessionDuration.compiled()
236 // calculate these once and for all
237 - ctx.state.account = await getHttpAccount(ctx) ?? getAccount(ctx.session?.username, false)
237 + const a = ctx.state.account = await getHttpAccount(ctx) ?? getAccount(ctx.session?.username, false)
238 + if (a && !accountCanLogin(a))
239 + ctx.state.account = undefined
240 const conn = ctx.state.connection = socket2connection(ctx.socket)
241 ctx.state.revProxyPath = ctx.get('x-forwarded-prefix')
242 if (conn)
src/perm.ts
+17 -10
@@ -17,6 +17,7 @@ export interface Account {
17 ignore_limits?: boolean
18 admin?: boolean
19 redirect?: string
20 + disabled?: boolean
21 }
22 interface Accounts { [username:string]: Account }
23
@@ -27,15 +28,15 @@ export function getCurrentUsername(ctx: Koa.Context): string {
28 }
29
30 // provides the username and all other usernames it inherits based on the 'belongs' attribute. Useful to check permissions
30 -export function getCurrentUsernameExpanded(ctx: Koa.Context) {
31 - const who = getCurrentUsername(ctx)
32 - if (!who)
33 - return []
34 - const ret = [who]
35 - for (const u of ret) {
31 +export function expandUsername(who: string): string[] {
32 + const ret = []
33 + const q = [who]
34 + for (const u of q) {
35 const a = getAccount(u)
37 - if (a?.belongs)
38 - ret.push(...a.belongs)
36 + if (!a || a.disabled) continue
37 + ret.push(u)
38 + if (a.belongs)
39 + q.push(...a.belongs)
40 }
41 return ret
42 }
@@ -129,7 +130,7 @@ export function renameAccount(from: string, to: string) {
130 }
131
132 // we consider all the following fields, when falsy, as equivalent to be missing. If this changes in the future, please adjust addAccount and setAccount
132 -const assignableProps: (keyof Account)[] = ['redirect','ignore_limits','belongs','admin']
133 +const assignableProps: (keyof Account)[] = ['redirect','ignore_limits','belongs','admin','disabled']
134
135 export function addAccount(username: string, props: Partial<Account>) {
136 username = normalizeUsername(username)
@@ -148,6 +149,8 @@ export function setAccount(acc: Account, changes: Partial<Account>) {
149 if (!v)
150 rest[k as keyof Account] = undefined
151 Object.assign(acc, rest)
152 + if (!acc.disabled)
153 + delete acc.disabled
154 if (changes.username)
155 renameAccount(acc.username, changes.username)
156 saveAccountsAsap()
@@ -182,7 +185,11 @@ export function accountHasPassword(account: Account) {
185 }
186
187 export function accountCanLogin(account: Account) {
185 - return accountHasPassword(account)
188 + return accountHasPassword(account) && !allDisabled(account)
189 +}
190 +
191 +function allDisabled(account: Account): boolean {
192 + return Boolean(account.disabled || account.belongs?.map(u => getAccount(u, false)).every(a => a && allDisabled(a)))
193 }
194
195 export function accountCanLoginAdmin(account: Account) {
src/vfs.ts
+2 -2
@@ -19,7 +19,7 @@ import _ from 'lodash'
19 import { defineConfig, setConfig } from './config'
20 import { HTTP_FOOL, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED } from './const'
21 import events from './events'
22 -import { getCurrentUsernameExpanded } from './perm'
22 +import { expandUsername, getCurrentUsername } from './perm'
23
24 export const WHO_ANYONE = true
25 export const WHO_NO_ONE = false
@@ -244,7 +244,7 @@ export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerm, ctx
244 if (Array.isArray(who)) {
245 const arr = who // shut up ts
246 // check if I or any ancestor match `who`, but cache ancestors' usernames inside context state
247 - const some = getOrSet(ctx.state, 'usernames', () => getCurrentUsernameExpanded(ctx))
247 + const some = getOrSet(ctx.state, 'usernames', () => expandUsername(getCurrentUsername(ctx)))
248 .some((u: string) => arr.includes(u))
249 return some ? 0 : HTTP_UNAUTHORIZED
250 }
tests/config.yaml
+11
@@ -40,6 +40,9 @@ vfs:
40 can_upload:
41 - admins
42 - source: tests/alfa.txt
43 + - name: for-disabled
44 + can_list:
45 + - disabled_account
46 - source: tests
47 - name: renameChild
48 children:
@@ -108,11 +111,19 @@ vfs:
111 can_see: true
112 children:
113 - name: hi
114 +enable_plugins: []
115 accounts:
116 rejetto:
117 srp: 45342499289060118491953864985904491552469027660732372413642410068701508460756016883587538941326969234657513592151654348700271313826438668687753011476364050308196782081349845543793071133962680938833562613037542533901213868728748288160539083771680823003225050120746634361389984903020864743362165811690166921198|19774996369091767466773785425767419323692622801450813744779985314019896203670047564571162840176134630605183591917392609010436089360338159426706132364650486739033105760664596040271171468723867761859495433833673834714019039663575196623574481195253082005981473801748471685440578887877536727938980101106408640345112365050882261328175189757112413706886166283150089763450343918391537516887689512711278015984288024916578966075794729866664625833406939511948734410200472601790276163270442611968041929062065290928050153275322302258046366227185195993685890141067515020089439381701920633381138539731031195766277095966471034607906
118 belongs:
119 - admins
120 + - disabled_account
121 + of_disabled:
122 + belongs:
123 + - disabled_account
124 + srp: 120804068635292437277659526629874168486493564624635377646330787993324029347643661070173511958514280976767927306751833743344847090685670483208190264705860902848531421420861927204883625771674418082140221216420339415820518698058241206651254261675616534491182986033693544092635419713292938817480339301130035142768|6390070233467830634736562883190487977343515956946496945249374625052474780962438110990674671048889570779960793274296584567905127170898293191856356572097228581183066941862764046917332873021156752168064068749102391176534012356053485735217052800610301632013415793338242451720832504147428409155945376375583183022415015336290830617108916724241486465509043174980735716739832823061125331466539384265398446680090973255616206398107827059604939857515439225687604138296426795701816020049847172782313576107757502401013344401388604328199230786618044783381875892970632930879062526080561936466730946652771701843119343086416435041725
125 + disabled_account:
126 + disabled: true
127 admins:
128 admin: true
129 version: 0.48.0
tests/test.ts
+9 -5
@@ -98,6 +98,7 @@ describe('basics', () => {
98 }))
99
100 testUpload('upload.missing perm', 401)
101 + it('of_disabled.cantLogin', () => login('of_disabled').then(() => { throw Error('logged in') }, () => 0))
102 })
103
104 describe('accounts', () => {
@@ -108,17 +109,20 @@ describe('accounts', () => {
109 })
110
111 describe('after-login', () => {
111 - before(() =>
112 - srpSequence(username, password, (cmd: string, params: any) =>
113 - reqApi(cmd, params, ()=>true)())
114 - )
115 - it('list protected', reqList('/for-admins/', { inList:['alfa.txt'] }))
112 + before(() => login(username))
113 + it('inherit.perm', reqList('/for-admins/', { inList:['alfa.txt'] }))
114 + it('inherit.disabled', reqList('/for-disabled/', 401))
115 testUpload('upload', 200)
116 testUpload('upload.bad path', 418, '../../')
117 after(() =>
118 rmSync(join(__dirname, 'temp'), { recursive: true}))
119 })
120
121 +function login(usr: string, pwd=password) {
122 + return srpSequence(usr, pwd, (cmd: string, params: any) =>
123 + reqApi(cmd, params, (x,res)=> !res.isAxiosError)())
124 +}
125 +
126 function testUpload(name: string, tester: Tester, path = 'temp/') {
127 it(name, req('PUT/for-admins/upload/'+join(path+'gpl.png'), tester, {
128 data: createReadStream(join(__dirname, 'page/gpl.png'))