admin/accounts: invalidate past sessions
Massimo Melina committed
Nov 4, 2023 at 16:44 UTC
7c1fb3264e59b18051c407e142c4b1e9573177c4
11 files changed
+44
-39
admin/src/AccountForm.ts
+9
-2
@@ -8,7 +8,7 @@ import { alertDialog, toast, useDialogBarColors } from './dialog'
8
import { IconBtn, isEqualLax, modifiedSx } from './misc'
9
import { Account, account2icon } from './AccountsPage'
10
import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
11
-import { Delete } from '@mui/icons-material'
11
+import { AutoDelete, Delete } from '@mui/icons-material'
12
import { isMobile } from './misc'
13
14
interface FormProps { account: Account, groups: string[], done: (username: string)=>void, reload: ()=>void, addToBar: ReactNode }
@@ -37,7 +37,14 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
37
icon: Delete,
38
title: "Delete",
39
confirm: "Delete?",
40
- onClick: () => apiCall('del_account', { username: account.username }).then(() => reload())
40
+ onClick: () => apiCall('del_account', { username: account.username }).then(reload)
41
+ }),
42
+ h(IconBtn, {
43
+ icon: AutoDelete,
44
+ title: "Invalidate past sessions",
45
+ doneMessage: true,
46
+ disabled: account.invalidated,
47
+ onClick: () => apiCall('invalidate_sessions', { username: account.username }).then(reload)
48
}),
49
addToBar,
50
h(Box, { flex:1 }),
admin/src/AccountsPage.ts
+3
-11
@@ -14,17 +14,9 @@ import { Flex } from './misc'
14
import { alertDialog, confirmDialog } from './dialog'
15
import { useSnapState } from './state'
16
import { importAccountsCsv } from './importAccountsCsv'
17
+import { AccountAdminSend } from '../../src/api.accounts'
18
18
-export interface Account {
19
- username: string
20
- hasPassword?: boolean
21
- admin?: boolean
22
- adminActualAccess?: boolean
23
- ignore_limits?: boolean
24
- disabled?: boolean
25
- redirect?: string
26
- belongs?: string[]
27
-}
19
+export type Account = AccountAdminSend
20
21
export default function AccountsPage() {
22
const { username } = useSnapState()
@@ -52,7 +44,7 @@ export default function AccountsPage() {
44
h(ListItemText, {}, username))))
45
)
46
: h(AccountForm, {
55
- account: selectedAccount || { username: '', hasPassword: sel === 'new-user' },
47
+ account: selectedAccount || { username: '', hasPassword: sel === 'new-user', adminActualAccess: false, invalidated: true },
48
groups: list.filter(x => !x.hasPassword).map( x => x.username ),
49
addToBar: isSideBreakpoint && h(IconBtn, { // not really useful, but users misled in thinking it's a dialog will find satisfaction in dismissing the form
50
icon: Close,
src/api.accounts.ts
+10
-11
@@ -2,26 +2,20 @@
2
3
import { changePasswordHelper, changeSrpHelper } from './api.helpers'
4
import { ApiError, ApiHandlers } from './apiMiddleware'
5
-import {
6
- Account,
7
- accountCanLoginAdmin,
8
- accountHasPassword,
9
- accountsConfig,
10
- addAccount,
11
- delAccount,
12
- getAccount,
13
- getCurrentUsername,
14
- setAccount
15
-} from './perm'
5
+import { Account, accountCanLoginAdmin, accountHasPassword, accountsConfig, addAccount, delAccount, getAccount,
6
+ setAccount } from './perm'
7
import _ from 'lodash'
8
import { HTTP_BAD_REQUEST, HTTP_CONFLICT, HTTP_NOT_FOUND } from './const'
9
+import { getCurrentUsername, invalidSessions } from './auth'
10
11
+export type AccountAdminSend = NonNullable<ReturnType<typeof prepareAccount>>
12
function prepareAccount(ac: Account | undefined) {
13
return ac && {
14
..._.omit(ac, ['password','hashed_password','srp']),
15
username: ac.username, // omit won't copy it because it's a hidden prop
16
hasPassword: accountHasPassword(ac),
17
adminActualAccess: accountCanLoginAdmin(ac),
18
+ invalidated: invalidSessions.has(ac.username),
19
}
20
}
21
@@ -66,6 +60,11 @@ const apis: ApiHandlers = {
60
return delAccount(username) ? {} : new ApiError(HTTP_BAD_REQUEST)
61
},
62
63
+ invalidate_sessions({ username }) {
64
+ invalidSessions.add(username)
65
+ return {}
66
+ },
67
+
68
async change_password_others({ username, newPassword }) {
69
const a = getAccount(username)
70
return a ? changePasswordHelper(a, newPassword)
src/api.auth.ts
+2
-2
@@ -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, accountCanLogin, getAccount, getCurrentUsername, getFromAccount } from './perm'
3
+import { Account, accountCanLogin, getAccount, getFromAccount } from './perm'
4
import { verifyPassword } from './crypt'
5
import { ApiError, ApiHandler } from './apiMiddleware'
6
import { SRPServerSessionStep1 } from 'tssrp6a'
@@ -9,7 +9,7 @@ import { ADMIN_URI, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST, HTTP_SERVER_ERROR, HTTP
9
import { changeSrpHelper, changePasswordHelper } from './api.helpers'
10
import { ctxAdminAccess } from './adminApis'
11
import { sessionDuration } from './middlewares'
12
-import { loggedIn, srpStep1 } from './auth'
12
+import { getCurrentUsername, loggedIn, srpStep1 } from './auth'
13
import { defineConfig } from './config'
14
15
const ongoingLogins:Record<string,SRPServerSessionStep1> = {} // store data that doesn't fit session object
src/api.monitor.ts
+1
-1
@@ -6,7 +6,7 @@ import { pendingPromise, typedEntries, wait } from './misc'
6
import { ApiHandlers, SendListReadable } from './apiMiddleware'
7
import Koa from 'koa'
8
import { totalGot, totalInSpeed, totalOutSpeed, totalSent } from './throttler'
9
-import { getCurrentUsername } from './perm'
9
+import { getCurrentUsername } from './auth'
10
11
const apis: ApiHandlers = {
12
src/auth.ts
+7
@@ -26,6 +26,10 @@ export async function srpCheck(username: string, password: string) {
26
return await step1.step2(client.A, client.M1).then(() => account, () => {})
27
}
28
29
+export function getCurrentUsername(ctx: Context): string {
30
+ return ctx.state.account?.username || ''
31
+}
32
+
33
// centralized log-in state
34
export async function loggedIn(ctx: Context, username: string | false) {
35
const s = ctx.session
@@ -35,6 +39,9 @@ export async function loggedIn(ctx: Context, username: string | false) {
39
delete s.username
40
return
41
}
42
+ invalidSessions.delete(username)
43
s.username = normalizeUsername(username)
44
await prepareState(ctx, async ()=>{}) // updating the state is necessary to send complete session data so that frontend shows admin button
45
}
46
+
47
+export const invalidSessions = new Set<string>() // since session are currently stored in cookies, we need to memorize this until we meet again
src/log.ts
+1
-1
@@ -8,7 +8,7 @@ import * as util from 'util'
8
import { stat } from 'fs/promises'
9
import _ from 'lodash'
10
import { createFileWithPath, prepareFolder } from './util-files'
11
-import { getCurrentUsername } from './perm'
11
+import { getCurrentUsername } from './auth'
12
import { DAY, makeNetMatcher, tryJson } from './misc'
13
import events from './events'
14
src/middlewares.ts
+5
-2
@@ -18,7 +18,7 @@ import { applyBlock } from './block'
18
import { accountCanLogin, getAccount } from './perm'
19
import { socket2connection, updateConnection, normalizeIp } from './connections'
20
import basicAuth from 'basic-auth'
21
-import { srpCheck } from './auth'
21
+import { invalidSessions, srpCheck } from './auth'
22
import { basename, dirname } from 'path'
23
import { pipeline } from 'stream/promises'
24
import formidable from 'formidable'
@@ -206,8 +206,11 @@ export function getProxyDetected() {
206
}
207
208
export const prepareState: Koa.Middleware = async (ctx, next) => {
209
- if (ctx.session)
209
+ if (ctx.session) {
210
+ if (invalidSessions.delete(ctx.session.username))
211
+ delete ctx.session.username
212
ctx.session.maxAge = sessionDuration.compiled()
213
+ }
214
// calculate these once and for all
215
const a = ctx.state.account = await urlLogin() || await getHttpAccount() || getAccount(ctx.session?.username, false)
216
if (a && !accountCanLogin(a))
src/perm.ts
-4
@@ -24,10 +24,6 @@ interface Accounts { [username:string]: Account }
24
25
let accounts: Accounts = {}
26
27
-export function getCurrentUsername(ctx: Koa.Context): string {
28
- return ctx.state.account?.username || ''
29
-}
30
-
27
// provides the username and all other usernames it inherits based on the 'belongs' attribute. Useful to check permissions
28
export function expandUsername(who: string): string[] {
29
const ret = []
src/upload.ts
+1
-1
@@ -14,7 +14,7 @@ import { defineConfig } from './config'
14
import { getFreeDiskSync } from './util-os'
15
import { socket2connection, updateConnection } from './connections'
16
import { roundSpeed } from './throttler'
17
-import { getCurrentUsername } from './perm'
17
+import { getCurrentUsername } from './auth'
18
import { setCommentFor } from './comments'
19
20
export const deleteUnfinishedUploadsAfter = defineConfig<undefined|number>('delete_unfinished_uploads_after', 86_400)
src/vfs.ts
+5
-4
@@ -3,15 +3,16 @@
3
import fs from 'fs/promises'
4
import { basename, dirname, join, resolve } from 'path'
5
import {
6
- dirStream, dirTraversal, enforceFinal, getOrSet, isDirectory, typedKeys, makeMatcher, setHidden, onlyTruthy,
7
- typedEntries, throw_, VfsPerms, Who, isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS
6
+ dirStream, dirTraversal, enforceFinal, getOrSet, isDirectory, makeMatcher, setHidden, onlyTruthy,
7
+ throw_, VfsPerms, Who, isWhoObject, WHO_ANY_ACCOUNT, defaultPerms, PERM_KEYS
8
} from './misc'
9
import Koa from 'koa'
10
import _ from 'lodash'
11
import { defineConfig, setConfig } from './config'
12
import { HTTP_FOOL, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED } from './const'
13
import events from './events'
14
-import { expandUsername, getCurrentUsername } from './perm'
14
+import { expandUsername } from './perm'
15
+import { getCurrentUsername } from './auth'
16
17
type Masks = Record<string, VfsNode & { maskOnly?: 'files' | 'folders' }>
18
@@ -210,7 +211,7 @@ export function statusCodeForMissingPerm(node: VfsNode, perm: keyof VfsPerms, ct
211
return some ? 0 : HTTP_UNAUTHORIZED
212
}
213
return typeof who === 'boolean' ? (who ? 0 : HTTP_FORBIDDEN)
213
- : who === WHO_ANY_ACCOUNT ? (ctx.state.account ? 0 : HTTP_UNAUTHORIZED)
214
+ : who === WHO_ANY_ACCOUNT ? (getCurrentUsername(ctx) ? 0 : HTTP_UNAUTHORIZED)
215
: throw_(Error('invalid permission: ' + JSON.stringify(who)))
216
}
217
}