admin/accounts: auto-login by ip
Massimo Melina committed
Apr 27, 2026 at 20:06 UTC
64a53ccbe710ceea3563c4da4e7150159a8ff5c9
5 files changed
+45
-18
admin/src/AccountForm.ts
+13
-10
@@ -84,9 +84,9 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
84
helperText: "To access THIS interface you are using right now",
85
...!account.admin && account.adminActualAccess && { value: true, disabled: true, helperText: "This permission is inherited. To disable it, act on the groups." },
86
},
87
- !isGroup && { k: 'require_password_change', comp: BoolField, xs: 12, sm: 6, lg: 6, helperText: "At next login" },
87
89
- { k: 'disable_password_change', label: "Password change", comp: SelectField, xs: 12, sm: 6, lg: isGroup ? 4 : 6,
88
+ !isGroup && { k: 'require_password_change', comp: BoolField, xs: 12, sm: 6, lg: 4, helperText: "At next login" },
89
+ { k: 'disable_password_change', label: "Password change", comp: SelectField, xs: 12, sm: 6, lg: isGroup ? 4 : 4,
90
defaultValue: null,
91
options: { [`Default (${values.canChangePassword ? 'Allowed' : 'Disabled'})`]: null, "Allowed": false, "Disabled": true },
92
},
@@ -103,19 +103,22 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
103
}),
104
),
105
isGroup && h(Alert, { severity: 'info' }, `To add users to this group, select the user and then click "Inherit"`),
106
- { k: 'belongs', comp: MultiSelectField, label: "Inherit from groups", options: belongsOptions, sm: 6,
106
+ { k: 'belongs', comp: MultiSelectField, label: "Inherit from groups", options: belongsOptions, sm: 6, lg: 4,
107
helperText: "Specify groups to inherit permissions from"
108
+ (!isGroup ? '' : ". A group can inherit from another group")
109
+ (belongsOptions.length ? '' : ". Now disabled because there are no groups to select, create one first.")
110
},
111
- { k: 'allow_net', comp: NetmaskField, label: "Allowed network address", sm: 6, placeholder: "Allow from any address" },
112
- { k: 'expire', label: "Expiration", xs: values.expire ? 12 : 6, comp: DateTimeField, toField: x => x && new Date(x),
113
- helperText: "When expired, login won't be allowed" },
114
- !values.expire && { k: 'days_to_live', xs: 12, sm: 6, comp: NumberField, step: 'any', min: 1/1000, // 10 minutes
115
- helperText: "Used to set expiration on first login" },
116
- { k: 'redirect', comp: VfsPathField, placeholder: "no", sm: 6,
111
+
112
+ { k: 'allow_net', comp: NetmaskField, label: "Allowed network address", sm: 6, lg: 4, placeholder: "any address" },
113
+ !isGroup && { k: 'auto_login_net', comp: NetmaskField, label: "Auto-login by IP address", sm: 6, lg: 4, placeholder: "none" },
114
+ { k: 'redirect', comp: VfsPathField, placeholder: "no", sm: 6, lg: 4,
115
helperText: "If you want this account to be redirected to a specific folder/address (or even file) at login time" },
118
- { k: 'notes', multiline: true, sm: 6 },
116
+
117
+ { k: 'expire', label: "Expiration", sm: 6, lg: 4, comp: DateTimeField, toField: x => x && new Date(x),
118
+ helperText: "When expired, login won't be allowed" },
119
+ { k: 'days_to_live', sm: 6, lg: 4, comp: NumberField, step: 'any', min: 1/1000, // 10 minutes
120
+ ...values.expire && { xs: 12, disabled: true, sx: { opacity: .2 } }, helperText: "Used to set expiration on first login" },
121
+ { k: 'notes', multiline: true, sm: 6, lg: 4 },
122
],
123
onError: alertDialog,
124
save: {
src/api.accounts.ts
+5
-5
@@ -2,7 +2,7 @@
2
3
import { ApiError, ApiHandlers } from './apiMiddleware'
4
import {
5
- Account, accountCanLoginAdmin, accountHasPassword, accounts, addAccount, delAccount, getAccount,
5
+ Account, accountCanLoginAdmin, accountHasLoginMethod, accountHasPassword, accounts, addAccount, delAccount, getAccount,
6
updateAccount, accountCanLogin, accountCanChangePassword, normalizeUsername
7
} from './perm'
8
import _ from 'lodash'
@@ -16,9 +16,9 @@ function prepareAccount(ac: Account | undefined) {
16
..._.omit(ac, ['password','hashed_password','srp']),
17
username: ac.username, // omit won't copy it because it's a hidden prop
18
hasPassword: accountHasPassword(ac),
19
- isGroup: !ac.plugin?.auth && !accountHasPassword(ac),
19
+ isGroup: !accountHasLoginMethod(ac),
20
adminActualAccess: accountCanLoginAdmin(ac),
21
- canLogin: accountHasPassword(ac) ? accountCanLogin(ac) : undefined,
21
+ canLogin: accountHasLoginMethod(ac) ? accountCanLogin(ac) : undefined,
22
canChangePassword: accountCanChangePassword(ac),
23
invalidated: invalidateSessionBefore.get(ac.username),
24
directMembers: Object.values(accounts.get()).filter(a => a.belongs?.includes(ac.username)).map(x => x.username),
@@ -34,7 +34,7 @@ function prepareAccount(ac: Account | undefined) {
34
}
35
}
36
37
-const ALLOWED_KEYS: (keyof Account)[] = ['admin', 'allow_net', 'belongs', 'days_to_live', 'disable_password_change',
37
+const ALLOWED_KEYS: (keyof Account)[] = ['admin', 'allow_net', 'auto_login_net', 'belongs', 'days_to_live', 'disable_password_change',
38
'disabled', 'expire', 'ignore_limits', 'notes', 'password', 'redirect', 'require_password_change', 'username']
39
40
export default {
@@ -96,4 +96,4 @@ export default {
96
return {}
97
},
98
99
-} satisfies ApiHandlers
\ No newline at end of file
99
+} satisfies ApiHandlers
src/middlewares.ts
+9
-2
@@ -6,7 +6,7 @@ import { API_URI, DEV } from './const'
6
import { ALLOW_SESSION_IP_CHANGE, DAY, hasDirTraversal, isLocalHost, netMatches, splitAt, stream2string, try_, tryJson } from './misc'
7
import { Readable } from 'stream'
8
import { applyBlock } from './block'
9
-import { Account, accountCanLogin, getAccount, getFromAccount } from './perm'
9
+import { Account, accountCanLogin, accounts, getAccount, getFromAccount } from './perm'
10
import { Connection, normalizeIp, socket2connection, updateConnectionForCtx } from './connections'
11
import { clearTextLogin, invalidateSessionBefore, setLoggedIn } from './auth'
12
import { constants } from 'zlib'
@@ -101,7 +101,8 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
101
}
102
// calculate these once and for all
103
ctx.state.connection = socket2connection(ctx.socket)!
104
- let a = await urlLogin() || await getHttpAccount()
104
+ // explicit credentials and existing sessions must take precedence, so a matching IP cannot override a chosen account
105
+ let a = await urlLogin() || await getHttpAccount() || !s?.username && autoLogin()
106
const loggedInNotBySession = a
107
ctx.state.account = a ||= getAccount(s?.username, false) // with least precedence, we consider session
108
if (a)
@@ -135,6 +136,12 @@ export const prepareState: Koa.Middleware = async (ctx, next) => {
136
}
137
catch {}
138
}
139
+
140
+ function autoLogin() {
141
+ // keep the mask direct so group inheritance cannot make identity depend on account order
142
+ return Object.values(accounts.get()).find(a =>
143
+ accountCanLogin(a) && a.auto_login_net && netMatches(ctx.ip, a.auto_login_net, true))
144
+ }
145
}
146
147
export function failAllowNet(ctx: Koa.Context, a: Account | undefined) {
src/perm.ts
+6
-1
@@ -23,6 +23,7 @@ export interface Account {
23
expire?: Date
24
days_to_live?: number // this is not inherited, but it will affect sub-accounts via 'expire'
25
allow_net?: string
26
+ auto_login_net?: string
27
require_password_change?: boolean // not inherited
28
notes?: string
29
plugin?: { id?: string, auth?: boolean, [rest: string]: unknown }
@@ -222,8 +223,12 @@ export function accountHasPassword(account: Account) {
223
return Boolean(account.password || account.srp)
224
}
225
226
+export function accountHasLoginMethod(account: Account) {
227
+ return Boolean(accountHasPassword(account) || account.plugin?.auth || account.auto_login_net)
228
+}
229
+
230
export function accountCanLogin(account: Account) {
226
- return (accountHasPassword(account) || account.plugin?.auth) && !accountIsDisabled(account)
231
+ return accountHasLoginMethod(account) && !accountIsDisabled(account)
232
}
233
234
export function accountIsDisabled(account: Account): boolean {
tests/test.ts
+12
@@ -799,6 +799,18 @@ describe('sessions', () => {
799
await reqApi('del_account', { username: user }, 200, adminReq)().catch(() => {})
800
}
801
})
802
+ test('auto_login_net.canLogin', async () => {
803
+ const user = `auto-login-${randomId(6)}`.toLowerCase()
804
+ const adminReq = { auth, jar: {} }
805
+ try {
806
+ await reqApi('add_account', { username: user, overwrite: true, auto_login_net: '::1' }, res => res?.username === user, adminReq)()
807
+ await reqApi('refresh_session', {}, res => res?.username === user, { jar: {} })()
808
+ await reqApi('refresh_session', {}, res => !res?.username, { baseUrl: BASE_URL_127, jar: {} })()
809
+ }
810
+ finally {
811
+ await reqApi('del_account', { username: user }, 200, adminReq)().catch(() => {})
812
+ }
813
+ })
814
test('change_srp enforces self/admin permissions', async () => {
815
const selfUser = `change-srp-self-${randomId(6)}`.toLowerCase()
816
const otherUser = `change-srp-other-${randomId(6)}`.toLowerCase()