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