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) {