@samitouri / QOSami-HFS / commits / 99ed6c72

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 {