@samitouri / QOSami-HFS / commits / bebc6fda

plugins: clearTextLogin event

Massimo Melina committed Feb 5, 2025 at 16:02 UTC bebc6fdaa19aab5a08e5a751db807396dc053614
12 files changed +68 -40
admin/src/AccountForm.ts
+9 -7
@@ -27,10 +27,11 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
27 ref.current?.querySelector('input')?.focus()
28 }, [JSON.stringify(account)]) //eslint-disable-line
29 const add = !account.username
30 - const group = !values.hasPassword
30 + const { isGroup } = values
31 const ref = useRef<HTMLFormElement>()
32 const expired = Boolean(values.expire)
33 const { members } = account
34 + const pluginAuth = !isGroup && account.plugin && !account.hasPassword
35 return h(Form, {
36 formRef: ref,
37 values,
@@ -57,14 +58,15 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
58 ...wantArray(addToBar),
59 ],
60 fields: [
60 - { k: 'username', label: group ? 'Group name' : undefined, autoComplete: 'off', required: true, lg: group ? 12 : 4,
61 + { k: 'username', label: isGroup ? 'Group name' : undefined, autoComplete: 'off', required: true, lg: isGroup ? 12 : 4,
62 getError: v => v !== account.username && apiCall('get_account', { username: v })
63 .then(got => got?.username === account.username ? "usernames are case-insensitive" : "already used", () => false),
64 },
64 - !group && { k: 'password', md: 6, lg: 4, type: 'password', autoComplete: 'new-password', required: add,
65 + pluginAuth && h(Alert, { severity: 'info' }, " Authentication handled by a plugin"),
66 + !isGroup && !pluginAuth && { k: 'password', md: 6, lg: 4, type: 'password', autoComplete: 'new-password', required: add,
67 label: add ? "Password" : "Change password"
68 },
67 - !group && { k: 'password2', md: 6, lg: 4, type: 'password', autoComplete: 'new-password', label: 'Repeat password',
69 + !isGroup && !pluginAuth && { k: 'password2', md: 6, lg: 4, type: 'password', autoComplete: 'new-password', label: 'Repeat password',
70 getError: (x, { values }) => (x||'') !== (values.password||'') && "Enter same password" },
71 { k: 'disabled', comp: BoolField, fromField: x=>!x, toField: x=>!x, label: "Enabled", xs: 12, sm: 6, lg: 8,
72 helperText: !values.disabled && values.canLogin === false ? h(Box, { color: 'warning.main', component: 'span' }, "Login is prevented because all of its groups are disabled")
@@ -78,13 +80,13 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
80 { k: 'disable_password_change', comp: BoolField, fromField: x=>!x, toField: x=>!x, label: "Allow password change", xs: true },
81 { k: 'require_password_change', comp: BoolField, xs: 12, lg: 4, helperText: "At first login" },
82 !members ? null
81 - : group && !members.length ? h(Box, {}, "No members")
83 + : isGroup && !members.length ? h(Box, {}, "No members")
84 : members.length > 0 && h(Box, {}, `${members.length} members: `,
85 reactJoin(', ', account.members?.map((u: string) => h(groups.includes(u) ? 'i' : 'span', {}, u))) ),
84 - group && h(Alert, { severity: 'info' }, `To add users to this group, select the user and then click "Inherit"`),
86 + isGroup && h(Alert, { severity: 'info' }, `To add users to this group, select the user and then click "Inherit"`),
87 { k: 'belongs', comp: MultiSelectField, label: "Inherit from groups", options: belongsOptions, sm: 6,
88 helperText: "Specify groups to inherit permissions from"
87 - + (!group ? '' : ". A group can inherit from another group")
89 + + (!isGroup ? '' : ". A group can inherit from another group")
90 + (belongsOptions.length ? '' : ". Now disabled because there are no groups to select, create one first.")
91 },
92 { k: 'allow_net', comp: NetmaskField, label: "Allowed network address", helperText: h(WildcardsSupported), sm: 6,
admin/src/AccountsPage.ts
+5 -4
@@ -26,7 +26,7 @@ export default function AccountsPage() {
26 if (Array.isArray(data?.list) && selectionMode)
27 setSel( sel.filter(u => data!.list.find((e:any) => e?.username === u)) ) // remove elements that don't exist anymore
28 }, [data]) //eslint-disable-line -- Don't fall for its suggestion to add `sel` here: we modify it and declaring it as a dependency would cause a logical loop
29 - const list = useMemo(() => data && _.sortBy(data.list, ['hasPassword', x => !x.adminActualAccess, 'username']), [data])
29 + const list = useMemo(() => data && _.sortBy(data.list, [x => !x.isGroup, x => !x.adminActualAccess, 'username']), [data])
30 const selectedAccount = selectionMode && _.find(list, { username: sel[0] })
31 const sideBreakpoint = 'md'
32 const isSideBreakpoint = useBreakpoint(sideBreakpoint)
@@ -45,7 +45,7 @@ export default function AccountsPage() {
45 : with_(selectedAccount || newAccount(), a =>
46 h(AccountForm, {
47 account: a,
48 - groups: list.filter(x => !x.hasPassword).map( x => x.username ),
48 + groups: list.filter(x => x.isGroup).map(x => x.username),
49 addToBar: isSideBreakpoint && [
50 h(Box, { flex:1 }),
51 account2icon(a, { fontSize: 'large', sx: { p: 1 }}),
@@ -64,7 +64,7 @@ export default function AccountsPage() {
64 const { close } = newDialog({
65 title: _.isString(sel) ? _.startCase(sel)
66 : sel.length > 1 ? "Multiple selection"
67 - : selectedAccount ? (selectedAccount.hasPassword ? "User: " : "Group: ") + selectedAccount.username
67 + : selectedAccount ? (selectedAccount.isGroup ? "Group: " : "User: ") + selectedAccount.username
68 : '?', // never
69 Content: () => sideContent,
70 onClose: selectNone,
@@ -148,6 +148,7 @@ export default function AccountsPage() {
148 adminActualAccess: false,
149 invalidated: undefined,
150 canLogin: true,
151 + isGroup: false,
152 members: [],
153 } satisfies Account
154 }
@@ -171,6 +172,6 @@ export default function AccountsPage() {
172 }
173
174 function account2icon(ac: Account, props={}) {
174 - return h(ac.hasPassword ? Person : Group, props)
175 + return h(ac.isGroup ? Group : Person, props)
176 }
177 }
\ No newline at end of file
admin/src/InstalledPlugins.ts
+1 -1
@@ -219,7 +219,7 @@ function UsernameField({ value, onChange, multiple, groups, ...rest }: FieldProp
219 const { data, element, loading } = useApiEx<{ list: Account[] }>('get_accounts')
220 return !loading && element || h((multiple ? MultiSelectField : SelectField) as Field<string>, {
221 value, onChange,
222 - options: data?.list.filter(x => groups === undefined || groups === !x.hasPassword).map(x => x.username),
222 + options: data?.list.filter(x => groups === undefined || groups === x.isGroup).map(x => x.username),
223 ...rest,
224 })
225 }
frontend/src/login.ts
+7 -3
@@ -5,7 +5,7 @@ import { state, useSnapState } from './state'
5 import { alertDialog, newDialog, toast } from './dialog'
6 import {
7 getHFS, hIcon, makeSessionRefresher, srpClientSequence, working, fallbackToBasicAuth,
8 - HTTP_CONFLICT, HTTP_UNAUTHORIZED, CFG,
8 + HTTP_CONFLICT, HTTP_UNAUTHORIZED, CFG, HTTP_FAILED_DEPENDENCY,
9 } from './misc'
10 import { createElement as h, Fragment, useEffect, useRef } from 'react'
11 import { reloadList } from './useFetchList'
@@ -16,11 +16,15 @@ const { t, useI18N } = i18n
16
17 async function login(username:string, password:string, extra?: object) {
18 const stopWorking = working()
19 - return srpClientSequence(username, password, apiCall, extra).then(res => {
19 + return srpClientSequence(username, password, apiCall, extra).catch(err => {
20 + if (err.code == HTTP_FAILED_DEPENDENCY)
21 + return apiCall('login', { username, password, ...extra })
22 + throw err
23 + }).then(res => {
24 refreshSession(res)
25 state.loginRequired = false
26 return res
23 - }, (err: any) => {
27 + }, err => {
28 throw Error(err.message === 'trust' ? t('login_untrusted', "Login aborted: server identity cannot be trusted")
29 : err.code === HTTP_UNAUTHORIZED ? t('login_bad_credentials', "Invalid credentials")
30 : err.code === HTTP_CONFLICT ? t('login_bad_cookies', "Cookies not working - login failed")
shared/api.ts
+1
@@ -10,6 +10,7 @@ export const API_URL = '/~/api/'
10
11 const timeoutByApi: Dict = {
12 loginSrp1: 90, // support antibrute
13 + login: 90,
14 get_status: 20, // can be lengthy on slow machines because of the find-process-on-busy-port feature
15 }
16
src/api.accounts.ts
+1
@@ -15,6 +15,7 @@ function prepareAccount(ac: Account | undefined) {
15 ..._.omit(ac, ['password','hashed_password','srp']),
16 username: ac.username, // omit won't copy it because it's a hidden prop
17 hasPassword: accountHasPassword(ac),
18 + isGroup: ac.plugin?.isGroup ?? !accountHasPassword(ac),
19 adminActualAccess: accountCanLoginAdmin(ac),
20 canLogin: accountHasPassword(ac) ? accountCanLogin(ac) : undefined,
21 invalidated: invalidateSessionBefore.get(ac.username),
src/api.auth.ts
+21 -2
@@ -3,22 +3,41 @@
3 import { Account, accountCanLogin, changeSrpHelper, expandUsername, getAccount, getFromAccount } from './perm'
4 import { ApiError, ApiHandler } from './apiMiddleware'
5 import { SRPServerSessionStep1 } from 'tssrp6a'
6 -import { ADMIN_URI, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_FOUND } from './const'
6 +import {
7 + ADMIN_URI,
8 + HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_FOUND, HTTP_FAILED_DEPENDENCY
9 +} from './const'
10 import { ctxAdminAccess } from './adminApis'
11 import { failAllowNet, sessionDuration } from './middlewares'
9 -import { getCurrentUsername, setLoggedIn, srpServerStep1 } from './auth'
12 +import { clearTextLogin, getCurrentUsername, setLoggedIn, srpServerStep1 } from './auth'
13 import { defineConfig } from './config'
14 import events from './events'
15
16 const ongoingLogins:Record<string,SRPServerSessionStep1> = {} // store data that doesn't fit session object
17 const keepSessionAlive = defineConfig('keep_session_alive', true)
18
19 +export const login: ApiHandler = async ({ username, password }, ctx) => {
20 + if (!username)
21 + return new ApiError(HTTP_BAD_REQUEST)
22 + if (!ctx.session)
23 + return new ApiError(HTTP_SERVER_ERROR)
24 + const account = await clearTextLogin(ctx, username, password, 'api')
25 + if (!account)
26 + return new ApiError(HTTP_UNAUTHORIZED)
27 + return {
28 + redirect: ctx.state.account?.redirect,
29 + ...await refresh_session({},ctx)
30 + }
31 +}
32 +
33 export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
34 if (!username)
35 return new ApiError(HTTP_BAD_REQUEST)
36 const account = getAccount(username)
37 if (!ctx.session)
38 return new ApiError(HTTP_SERVER_ERROR)
39 + if (account && !account.srp && account.plugin) // tell client to do clear-text login, before firing attemptingLogin, before triggering anti-brute
40 + return new ApiError(HTTP_FAILED_DEPENDENCY)
41 if ((await events.emitAsync('attemptingLogin', { ctx, username }))?.isDefaultPrevented()) return
42 if (!account || !accountCanLogin(account)) { // TODO simulate fake account to prevent knowing valid usernames
43 ctx.logExtra({ u: username })
src/auth.ts
+13
@@ -38,6 +38,19 @@ export function getCurrentUsername(ctx: Context): string {
38 return ctx.state.account?.username || ''
39 }
40
41 +export async function clearTextLogin(ctx: Context, u: string, p: string, via: string) {
42 + if ((await events.emitAsync('attemptingLogin', { ctx, username: u, via }))?.isDefaultPrevented()) return
43 + const plugins = await events.emitAsync('clearTextLogin', { ctx, username: u, password: p, via }) // provide clear password to plugins
44 + const a = plugins?.some(Boolean) ? getAccount(u) : await srpCheck(u, p)
45 + if (a) {
46 + await setLoggedIn(ctx, a.username)
47 + ctx.headers['x-username'] = a.username // give an easier way to determine if the login was successful
48 + }
49 + else if (u)
50 + events.emit('failedLogin', ctx, { username: u, via })
51 + return a
52 +}
53 +
54 // centralized log-in state
55 export async function setLoggedIn(ctx: Context, username: string | false) {
56 const s = ctx.session
src/const.ts
+1 -1
@@ -9,7 +9,7 @@ import { formatTimestamp } from './cross'
9 import { argv } from './argv'
10 export * from './cross-const'
11
12 -export const API_VERSION = 11.6
12 +export const API_VERSION = 12
13 export const COMPATIBLE_API_VERSION = 1 // while changes in the api are not breaking, this number stays the same, otherwise it is made equal to API_VERSION
14
15 // you can add arguments with this file, currently used for the update process on mac/linux
src/events.ts
+1 -1
@@ -74,7 +74,7 @@ export class BetterEventEmitter {
74 async emitAsync(event: string, ...args: any[]) {
75 const syncRet = this.emit(event, ...args)
76 if (!syncRet) return
77 - const asyncRet = await Promise.all(syncRet)
77 + const asyncRet: typeof syncRet = await Promise.all(syncRet)
78 return Object.assign(asyncRet, {
79 isDefaultPrevented: () => syncRet.isDefaultPrevented()
80 || asyncRet.some((r: any) => r === this.preventDefault)
src/middlewares.ts
+5 -19
@@ -8,7 +8,7 @@ import { Readable } from 'stream'
8 import { applyBlock } from './block'
9 import { Account, accountCanLogin, getAccount, getFromAccount } from './perm'
10 import { Connection, normalizeIp, socket2connection, updateConnectionForCtx } from './connections'
11 -import { invalidateSessionBefore, setLoggedIn, srpCheck } from './auth'
11 +import { clearTextLogin, invalidateSessionBefore } from './auth'
12 import { constants } from 'zlib'
13 import { getHttpsWorkingPort } from './listen'
14 import { defineConfig } from './config'
@@ -112,14 +112,12 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
112 updateConnectionForCtx(ctx)
113 await next()
114
115 - async function urlLogin() {
115 + function urlLogin() {
116 const { login } = ctx.query
117 if (!login) return
118 const [u, p] = splitAt(':', String(login))
119 - const a = await doLogin(u, p, 'url')
120 - if (!a) return
119 ctx.redirect(ctx.originalUrl.slice(0, -ctx.querystring.length-1)) // redirect to hide credentials
122 - return a
120 + return u && clearTextLogin(ctx, u, p, 'url')
121 }
122
123 function getHttpAccount() {
@@ -127,23 +125,11 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
125 if (!b64) return
126 try {
127 const [u, p] = atob(b64).split(':')
130 - return doLogin(u!, p||'', 'header')
128 + if (!u || u === ctx.session?.username) return // providing credentials, but not needed
129 + return clearTextLogin(ctx, u, p||'', 'header')
130 }
131 catch {}
132 }
134 -
135 - async function doLogin(u: string, p: string, via: string) {
136 - if (!u || u === ctx.session?.username) return // providing credentials, but not needed
137 - if ((await events.emitAsync('attemptingLogin', { ctx, username: u, via }))?.isDefaultPrevented()) return
138 - const a = await srpCheck(u, p)
139 - if (a) {
140 - await setLoggedIn(ctx, a.username)
141 - ctx.headers['x-username'] = a.username // give an easier way to determine if the login was successful
142 - }
143 - else if (u)
144 - events.emit('failedLogin', ctx, { username: u, via })
145 - return a
146 - }
133 }
134
135 export function failAllowNet(ctx: Koa.Context, a: Account | undefined) {
src/perm.ts
+3 -2
@@ -24,6 +24,7 @@ export interface Account {
24 days_to_live?: number // this is not inherited, but it will affect sub-accounts via 'expire'
25 allow_net?: string
26 require_password_change?: boolean
27 + plugin?: { isGroup?: boolean, [rest: string]: unknown }
28 }
29 interface Accounts { [username:string]: Account }
30
@@ -197,7 +198,7 @@ export function accountHasPassword(account: Account) {
198 }
199
200 export function accountCanLogin(account: Account) {
200 - return accountHasPassword(account) && !allDisabled(account)
201 + return (accountHasPassword(account) || account.plugin && !account.plugin.isGroup) && !allDisabled(account)
202 }
203
204 function allDisabled(account: Account): boolean {
@@ -208,7 +209,7 @@ function allDisabled(account: Account): boolean {
209 }
210
211 export function accountCanLoginAdmin(account: Account) {
211 - return accountCanLogin(account) && Boolean(getFromAccount(account, a => a.admin))
212 + return accountCanLogin(account) && getFromAccount(account, a => a.admin) || false
213 }
214
215 export async function changeSrpHelper(account: Account, salt: string, verifier: string) {