admin/accounts: new "require password change" option #562

Massimo Melina committed Jan 24, 2025 at 00:54 UTC 7205d21e60e6ce64b889e4518be4e20795afc85d
9 files changed +48 -28
admin/src/AccountForm.ts
+2 -1
@@ -70,11 +70,12 @@ export default function AccountForm({ account, done, groups, addToBar, reload }:
70 : "Login is prevented if account is disabled, or all its groups are disabled" },
71 { k: 'ignore_limits', comp: BoolField, xs: true,
72 helperText: values.ignore_limits ? "Speed limits don't apply to this account" : "Speed limits apply to this account" },
73 - { k: 'admin', comp: BoolField, fromField: (v:boolean) => v||null, label: "Admin-panel access", xs: 12, sm: 6, lg: 8,
73 + { k: 'admin', comp: BoolField, fromField: (v:boolean) => v||null, label: "Admin-panel access", xs: 12, sm: 6, lg: 4,
74 helperText: "To access THIS interface you are using right now",
75 ...!account.admin && account.adminActualAccess && { value: true, disabled: true, helperText: "This permission is inherited. To disable it, act on the groups." },
76 },
77 { k: 'disable_password_change', comp: BoolField, fromField: x=>!x, toField: x=>!x, label: "Allow password change", xs: true },
78 + { k: 'require_password_change', comp: BoolField, xs: 12, lg: 4, helperText: "At first login" },
79 !members ? null
80 : group && !members.length ? h(Box, {}, "No members")
81 : members.length > 0 && h(Box, {}, `${members.length} members: `,
config.md
+2 -1
@@ -215,7 +215,8 @@ For each account entries, this is the list of properties you can have:
215 - `disabled` prevents using this account. Default is false.
216 - `expire` account won't work once the time has passed this timestamp. Use JSON timestamp syntax. Default is none.
217 - `days_to_live` used to set `expire` on first login. Default is none.
218 -- `disable_password_change` set `true` if you want to forbid password change for users. Default is `false`.
218 +- `disable_password_change` set `true` if you want to forbid password change for this user. Default is `false`.
219 +- `require_password_change` set `true` if you want to require password change for this user. Default is `false`.
220 - `allow_net` a mask of addresses to restrict the access of the account
221
222 ### Specify another file
frontend/src/UserPanel.ts
+23 -18
@@ -1,6 +1,6 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 -import { useSnapState } from './state'
3 +import { state, useSnapState } from './state'
4 import { createElement as h } from 'react'
5 import { alertDialog, newDialog, promptDialog } from './dialog'
6 import { createVerifierAndSalt, SRPParameters, SRPRoutines } from 'tssrp6a'
@@ -27,23 +27,7 @@ export default function showUserPanel() {
27 label: t`Change password`,
28 id: 'change-password',
29 onClickAnimation: false,
30 - async onClick() {
31 - const pwd = await promptDialog(t('enter_pass', "Enter new password"), { type: 'password' })
32 - if (!pwd) return
33 - const check = await promptDialog(t('enter_pass2', "RE-enter same new password"), { type: 'password' })
34 - if (!check) return
35 - if (check !== pwd)
36 - return alertDialog(t('pass2_mismatch', "The second password you entered did not match the first. Procedure aborted."), 'warning')
37 - const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
38 - const res = await createVerifierAndSalt(srp6aNimbusRoutines, snap.username, pwd)
39 - try {
40 - await apiCall('change_my_srp', { salt: String(res.s), verifier: String(res.v) }, { modal: working })
41 - return alertDialog(t('password_changed', "Password changed"))
42 - }
43 - catch(e) {
44 - return alertDialog(e as Error)
45 - }
46 - }
30 + onClick: changePassword,
31 }),
32 h(Btn, {
33 icon: 'logout',
@@ -59,3 +43,24 @@ export default function showUserPanel() {
43 }
44 })
45 }
46 +
47 +export async function changePassword(required=false) {
48 + const pwd = await promptDialog(t('enter_pass', "Enter new password"), {
49 + type: 'password',
50 + helperText: required && t('required_change_password', "You are required to change your password")
51 + })
52 + if (!pwd) return
53 + const check = await promptDialog(t('enter_pass2', "RE-enter same new password"), { type: 'password' })
54 + if (!check) return
55 + if (check !== pwd)
56 + return alertDialog(t('pass2_mismatch', "The second password you entered did not match the first. Procedure aborted."), 'warning')
57 + const srp6aNimbusRoutines = new SRPRoutines(new SRPParameters())
58 + const res = await createVerifierAndSalt(srp6aNimbusRoutines, state.username, pwd)
59 + try {
60 + await apiCall('change_my_srp', { salt: String(res.s), verifier: String(res.v) }, { modal: working })
61 + return alertDialog(t('password_changed', "Password changed"))
62 + }
63 + catch(e) {
64 + return alertDialog(e as Error)
65 + }
66 +}
\ No newline at end of file
frontend/src/login.ts
+10 -4
@@ -10,23 +10,22 @@ import {
10 import { createElement as h, Fragment, useEffect, useRef } from 'react'
11 import { reloadList } from './useFetchList'
12 import { Checkbox, CustomCode } from './components'
13 +import { changePassword } from './UserPanel'
14 import i18n from './i18n'
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 - stopWorking()
20 refreshSession(res)
21 state.loginRequired = false
22 return res
23 }, (err: any) => {
24 - stopWorking()
24 throw Error(err.message === 'trust' ? t('login_untrusted', "Login aborted: server identity cannot be trusted")
25 : err.code === HTTP_UNAUTHORIZED ? t('login_bad_credentials', "Invalid credentials")
26 : err.code === HTTP_CONFLICT ? t('login_bad_cookies', "Cookies not working - login failed")
27 : t(err.message || String(err)))
29 - })
28 + }).finally(stopWorking)
29 }
30
31 const refreshSession = makeSessionRefresher(state)
@@ -147,7 +146,14 @@ export async function loginDialog(closable=true, reloadAfter=true) {
146 }
147
148 export function useAuthorized() {
150 - const { loginRequired } = useSnapState()
149 + const { loginRequired, username } = useSnapState()
150 + const last = useRef('')
151 + useEffect(() => {
152 + if (last.current === username) return // need to remember because we are not undoing our useEffect
153 + last.current = username
154 + if (username && getHFS().session?.requireChangePassword)
155 + changePassword(true)
156 + }, [username])
157 useEffect(() => {
158 if (!loginRequired)
159 closeLoginDialog?.()
shared/dialogs.ts
+2 -1
@@ -75,10 +75,11 @@ export function isDescendant(child: Node | null | undefined, parentMatch: Node |
75
76 let waitClosing = Promise.resolve()
77 let ignorePopState = false
78 -function back() {
78 +async function back() {
79 ignorePopState = true
80 let was = history.state
81 history.back()
82 + await waitClosing
83 return waitClosing = new Promise<void>(res => {
84 const h = setInterval(() => was !== history.state && res() , 10)
85 setTimeout(() => clearTimeout(h), 500)
src/api.auth.ts
+4 -1
@@ -85,6 +85,7 @@ export const refresh_session: ApiHandler = async ({}, ctx) => {
85 expandedUsername: expandUsername(username),
86 adminUrl: ctxAdminAccess(ctx) ? ctx.state.revProxyPath + ADMIN_URI : undefined,
87 canChangePassword: canChangePassword(ctx.state.account),
88 + requireChangePassword: ctx.state.account?.require_password_change,
89 exp: keepSessionAlive.get() ? new Date(Date.now() + sessionDuration.compiled()) : undefined,
90 accountExp: ctx.state.account?.expire,
91 }
@@ -93,7 +94,9 @@ export const refresh_session: ApiHandler = async ({}, ctx) => {
94 export const change_my_srp: ApiHandler = async ({ salt, verifier }, ctx) => {
95 const a = ctx.state.account
96 return !a || !canChangePassword(a) ? new ApiError(HTTP_UNAUTHORIZED)
96 - : changeSrpHelper(a, salt, verifier)
97 + : changeSrpHelper(a, salt, verifier).then(() => {
98 + delete a.require_password_change
99 + })
100 }
101
102 function canChangePassword(account: Account | undefined) {
src/langs/hfs-lang-en.json
+2 -1
@@ -181,6 +181,7 @@
181
182 "Calculate": "Calculate",
183 "Creation": "Creation",
184 - "creation": "creation"
184 + "creation": "creation",
185 + "required_change_password": "You are required to change your password"
186 }
187 }
src/langs/hfs-lang-it.json
+2 -1
@@ -173,6 +173,7 @@
173
174 "Calculate": "Calcola",
175 "Creation": "Creazione",
176 - "creation": "creazione"
176 + "creation": "creazione",
177 + "required_change_password": "È necessario cambiare la password"
178 }
179 }
src/perm.ts
+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 + require_password_change?: boolean
27 }
28 interface Accounts { [username:string]: Account }
29