plugins: better api for authentication
Massimo Melina committed
Feb 27, 2025 at 17:51 UTC
99ed6c72b867fe458af98ac2b054229df6653a2e
8 files changed
+37
-25
admin/src/AccountForm.ts
+1
-1
@@ -31,7 +31,7 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
31
const ref = useRef<HTMLFormElement>()
32
const expired = Boolean(values.expire)
33
const { members } = account
34
- const pluginAuth = !isGroup && account.plugin && !account.hasPassword
34
+ const pluginAuth = account.plugin?.auth
35
return h(Form, {
36
formRef: ref,
37
values,
admin/src/AccountsPage.ts
+1
-1
@@ -157,7 +157,7 @@ export default function AccountsPage() {
157
adminActualAccess: false,
158
invalidated: undefined,
159
canLogin: true,
160
- isGroup: false,
160
+ isGroup: sel === 'new-group',
161
members: [],
162
directMembers: [],
163
} satisfies Account
dev-plugins.md
+26
-14
@@ -285,18 +285,22 @@ The `api` object you get as parameter of the `init` contains the following:
285
286
- `getAccount(username: string): Account | undefined` retrieve an account object, or undefined of not found.
287
The `Account` object has the following properties:
288
- `username: string`
289
- `srp?: string` if this value is not present, then it's a group
290
- `belongs?: string[]` list of groups this account belongs to
291
- `ignore_limits?: boolean` don't apply limits to this account
292
- `disable_password_change?: boolean` don't allow password change
293
- `admin?: boolean` allow access to admin-panel
294
- `redirect?: string` redirect to this URL as soon as the user logs in
295
- `disabled?: boolean` forbid login
296
- `expire?: Date` account expiration date
297
- `days_to_live?: number` set expiration date (after this many days) automatically at next login
298
- `allow_net?: string` allow login of this account only from this network mask
299
- `require_password_change?: boolean` ask user to change password at next login
288
+ `username: string`
289
+ `srp?: string` if this value is not present, then it's a group
290
+ `belongs?: string[]` list of groups this account belongs to
291
+ `ignore_limits?: boolean` don't apply limits to this account
292
+ `disable_password_change?: boolean` don't allow password change
293
+ `admin?: boolean` allow access to admin-panel
294
+ `redirect?: string` redirect to this URL as soon as the user logs in
295
+ `disabled?: boolean` forbid login
296
+ `expire?: Date` account expiration date
297
+ `days_to_live?: number` set expiration date (after this many days) automatically at next login
298
+ `allow_net?: string` allow login of this account only from this network mask
299
+ `require_password_change?: boolean` ask user to change password at next login
300
+ `plugin?: object` this can contain any information needed by plugins. It's free-form, but some fields are standard:
301
+ - `id?: string` name of the plugin responsible for this account
302
+ - `auth?: true` if the plugin is responsible for this authentication.
303
+ It will cause HFS to fallback to `clearTextLogin`, and the plugin shall respond to its corresponding event.
304
305
- `getAccounts(): string[]` retrieve list of all usernames
306
@@ -311,6 +315,8 @@ The `api` object you get as parameter of the `init` contains the following:
315
316
- `_` [lodash library](https://lodash.com/docs/)
317
318
+- `setInterval`, `setTimeout` same as standard js functions, but will automatically cancel if the plugin is unloaded.
319
+
320
## Frontend specific
321
322
The following information applies to the default frontend, and may not apply to a custom one.
@@ -407,7 +413,6 @@ This is a list of available frontend-events, with respective object parameter an
413
- `n: string` name of the entry, including relative path when searched in sub-folders.
414
- `uri: string` relative url of the entry.
415
- `s?: number` size of the entry, in bytes. It may be missing, for example for folders.
410
- - `t?: Date` generic timestamp, combination of creation-time and modified-time.
416
- `c?: Date` creation-time.
417
- `m?: Date` modified-time.
418
- `p?: string` permissions missing
@@ -563,6 +568,10 @@ This section is still partially documented, and you may need to have a look at t
568
- async supported
569
- preventable
570
- `failedLogin`
571
+- `clearTextLogin` give plugins the chance to authenticate users
572
+ - parameters: { ctx, username, password, via: 'url' | 'header' }
573
+ - async supported
574
+ - return: `true` to consider authentication done
575
- `finalizingLogin`
576
- parameters: { ctx, username, inputs }
577
- inputs: object
@@ -782,8 +791,11 @@ If you want to override a text regardless of the language, use the special langu
791
## API version history
792
793
- 12.0 (v0.57.0)
785
- - backend event: finalizingLogin, httpsServerOptions
794
+ - backend event: finalizingLogin, httpsServerOptions, clearTextLogin
795
- exports.changelog
796
+ - automatic unload of api.events listeners
797
+ - removed DirEntry.t
798
+ - api.setInterval, setTimeout
799
- 11.6 (v0.56.0)
800
- api.setError
801
- frontend events: afterBreadcrumbs, afterFolderStats, afterFilter
frontend/src/login.ts
+2
-2
@@ -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, HTTP_FAILED_DEPENDENCY,
8
+ HTTP_CONFLICT, HTTP_UNAUTHORIZED, CFG, HTTP_METHOD_NOT_ALLOWED,
9
} from './misc'
10
import { createElement as h, Fragment, useEffect, useRef } from 'react'
11
import { reloadList } from './useFetchList'
@@ -17,7 +17,7 @@ const { t, useI18N } = i18n
17
async function login(username:string, password:string, extra?: object) {
18
const stopWorking = working()
19
return srpClientSequence(username, password, apiCall, extra).catch(err => {
20
- if (err.code == HTTP_FAILED_DEPENDENCY)
20
+ if (err.code == HTTP_METHOD_NOT_ALLOWED)
21
return apiCall('login', { username, password, ...extra })
22
throw err
23
}).then(res => {
src/api.accounts.ts
+1
-1
@@ -15,7 +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),
18
+ isGroup: !ac.plugin?.auth && !accountHasPassword(ac),
19
adminActualAccess: accountCanLoginAdmin(ac),
20
canLogin: accountHasPassword(ac) ? accountCanLogin(ac) : undefined,
21
invalidated: invalidateSessionBefore.get(ac.username),
src/api.auth.ts
+3
-3
@@ -5,7 +5,7 @@ import { ApiError, ApiHandler } from './apiMiddleware'
5
import { SRPServerSessionStep1 } from 'tssrp6a'
6
import {
7
ADMIN_URI,
8
- HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_FOUND, HTTP_FAILED_DEPENDENCY
8
+ HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED
9
} from './const'
10
import { ctxAdminAccess } from './adminApis'
11
import { failAllowNet, sessionDuration } from './middlewares'
@@ -41,8 +41,8 @@ export const loginSrp1: ApiHandler = async ({ username }, ctx) => {
41
const account = getAccount(username)
42
if (!ctx.session)
43
return new ApiError(HTTP_SERVER_ERROR)
44
- if (account && !account.srp && account.plugin) // tell client to do clear-text login, before firing attemptingLogin, before triggering anti-brute
45
- return new ApiError(HTTP_FAILED_DEPENDENCY)
44
+ if (account?.plugin?.auth) // tell client to do clear-text login, before firing attemptingLogin, before triggering anti-brute
45
+ return new ApiError(HTTP_METHOD_NOT_ALLOWED)
46
if ((await events.emitAsync('attemptingLogin', { ctx, username }))?.isDefaultPrevented()) return
47
if (!account || !accountCanLogin(account)) { // TODO simulate fake account to prevent knowing valid usernames
48
ctx.logExtra({ u: username })
src/auth.ts
+1
-1
@@ -41,7 +41,7 @@ export function getCurrentUsername(ctx: Context): string {
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)
44
+ const a = plugins?.some(x => x === true) ? 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
src/perm.ts
+2
-2
@@ -25,7 +25,7 @@ export interface Account {
25
allow_net?: string
26
require_password_change?: boolean
27
notes?: string
28
- plugin?: { isGroup?: boolean, [rest: string]: unknown }
28
+ plugin?: { id?: string, auth?: boolean, [rest: string]: unknown }
29
}
30
interface Accounts { [username:string]: Account }
31
@@ -199,7 +199,7 @@ export function accountHasPassword(account: Account) {
199
}
200
201
export function accountCanLogin(account: Account) {
202
- return (accountHasPassword(account) || account.plugin && !account.plugin.isGroup) && !allDisabled(account)
202
+ return (accountHasPassword(account) || account.plugin?.auth) && !allDisabled(account)
203
}
204
205
function allDisabled(account: Account): boolean {