main
ts 166 lines 6.89 KB
Raw
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 {
4 Account, accountCanLogin, accountIsDisabled, accountCanChangePassword, expandUsername, getAccount, normalizeUsername,
5 updateAccount, saveSrpInfo
6 } from './perm'
7 import { ApiError, ApiHandler, ApiHandlers } from './apiMiddleware'
8 import { SRPServerSessionStep1 } from 'tssrp6a'
9 import {
10 ADMIN_URI,
11 HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST, HTTP_SERVER_ERROR, HTTP_CONFLICT, HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED
12 } from './const'
13 import { ctxAdminAccess } from './adminApis'
14 import { failAllowNet, sessionDuration } from './middlewares'
15 import { clearTextLogin, getCurrentUsername, setLoggedIn, srpServerStep1 } from './auth'
16 import { defineConfig } from './config'
17 import events from './events'
18 import { apiAssertTypes } from './misc'
19 import { createHmac, randomBytes, randomUUID } from 'node:crypto'
20
21 const ongoingLogins:Record<string,SRPServerSessionStep1> = {} // store data that doesn't fit session object
22 const keepSessionAlive = defineConfig('keep_session_alive', true)
23 const fakeSrpSecret = randomBytes(32)
24
25 const refresh_session: ApiHandler = async ({}, ctx) => {
26 const username = getCurrentUsername(ctx)
27 const isAdmin = ctxAdminAccess(ctx) || undefined
28 return !ctx.session ? new ApiError(HTTP_SERVER_ERROR) : {
29 username,
30 expandedUsername: Array.from(expandUsername(username)),
31 isAdmin,
32 adminUrl: isAdmin && ctx.state.revProxyPath + ADMIN_URI,
33 canChangePassword: accountCanChangePassword(ctx.state.account),
34 requireChangePassword: ctx.state.account?.require_password_change,
35 exp: username && keepSessionAlive.get() ? new Date(Date.now() + sessionDuration.compiled()) : undefined,
36 accountExp: ctx.state.account?.expire,
37 }
38 }
39
40 export const authApis = {
41
42 async login({ username, password }, ctx) {
43 if (!username)
44 return new ApiError(HTTP_BAD_REQUEST)
45 if (!ctx.session)
46 return new ApiError(HTTP_SERVER_ERROR)
47 try {
48 const account = await clearTextLogin(ctx, username, password, 'api')
49 if (!account)
50 return new ApiError(HTTP_UNAUTHORIZED)
51 await setLoggedIn(ctx, account.username)
52 }
53 catch (e) {
54 return new ApiError(HTTP_UNAUTHORIZED, String(e))
55 }
56 return {
57 redirect: ctx.state.account?.redirect,
58 ...await refresh_session({}, ctx)
59 }
60 },
61
62 async loginSrp1({ username }, ctx) {
63 apiAssertTypes({ string: { username } })
64 if (!username)
65 return new ApiError(HTTP_BAD_REQUEST)
66 const account = getAccount(username)
67 if (!ctx.session)
68 return new ApiError(HTTP_SERVER_ERROR)
69 if (account?.plugin?.auth) // tell client to do clear-text login, before firing attemptingLogin, before triggering anti-brute
70 return new ApiError(HTTP_METHOD_NOT_ALLOWED)
71 if ((await events.emitAsync('attemptingLogin', { ctx, username }))?.isDefaultPrevented()) return
72 if (account && !accountCanLogin(account)) {
73 ctx.logExtra({ u: username })
74 ctx.state.dontLog = false // log even if log_api is false
75 return unauthorized(accountIsDisabled(account) ? 'Account disabled' : undefined)
76 }
77 if (account && failAllowNet(ctx, account))
78 return unauthorized()
79 try { // unknown users complete step 1 so only a full failed login can reveal and penalize the attempt
80 const { srpServer, ...rest } = await srpServerStep1(account || fakeSrpAccount(username))
81 // keep the public handshake identifier independent of predictable application PRNG state
82 const sid = randomUUID()
83 ongoingLogins[sid] = srpServer
84 setTimeout(()=> delete ongoingLogins[sid], 60_000)
85 ctx.session.loggingIn = { username, sid } // temporarily store until process is complete
86 return rest
87 }
88 catch (code: any) {
89 return new ApiError(code)
90 }
91
92 function unauthorized(message?: string) {
93 events.emit('failedLogin', { ctx, username })
94 return new ApiError(HTTP_UNAUTHORIZED, message)
95 }
96
97 function fakeSrpAccount(username: string): Account {
98 username = normalizeUsername(username)
99 return {
100 username,
101 // process-secret derivation keeps fake credentials stable per username without storing attacker-controlled names
102 srp: `${derive('salt')}|${derive('verifier')}`,
103 }
104
105 function derive(field: string) {
106 return BigInt('0x' + createHmac('sha256', fakeSrpSecret).update(field).update(username).digest('hex')).toString()
107 }
108 }
109 },
110
111 async loginSrp2({ pubKey, proof }, ctx) {
112 if (!ctx.session)
113 return new ApiError(HTTP_SERVER_ERROR)
114 if (!ctx.session.loggingIn)
115 return new ApiError(HTTP_CONFLICT)
116 const { username, sid } = ctx.session.loggingIn
117 delete ctx.session.loggingIn
118 const step1 = ongoingLogins[sid]
119 if (!step1)
120 return new ApiError(HTTP_NOT_FOUND)
121 try {
122 const M2 = await step1.step2(BigInt(pubKey), BigInt(proof))
123 .catch(() => { throw '' })
124 await setLoggedIn(ctx, username)
125 return {
126 proof: String(M2),
127 redirect: ctx.state.account?.redirect,
128 ...await refresh_session({}, ctx)
129 }
130 }
131 catch(e) {
132 ctx.logExtra({ u: username })
133 ctx.state.dontLog = false // log even if log_api is false
134 events.emit('failedLogin', { ctx, username })
135 return new ApiError(HTTP_UNAUTHORIZED, e ? String(e) : undefined)
136 }
137 finally {
138 delete ongoingLogins[sid]
139 }
140 },
141
142 // this api is here for consistency, but frontend is actually using
143 async logout({}, ctx) {
144 if (!ctx.session)
145 return new ApiError(HTTP_SERVER_ERROR)
146 await setLoggedIn(ctx, false)
147 // 401 is a convenient code for OK: the browser clears a possible http authentication (hopefully), and Admin automatically triggers login dialog
148 return new ApiError(HTTP_UNAUTHORIZED)
149 },
150
151 refresh_session,
152
153 async change_srp({ username, salt, verifier }, ctx) {
154 const a = username && getAccount(username)
155 const can = a && (ctxAdminAccess(ctx) || username === getCurrentUsername(ctx) && accountCanChangePassword(a))
156 if (!can)
157 return new ApiError(HTTP_UNAUTHORIZED)
158 if (!salt || !verifier)
159 return new ApiError(HTTP_BAD_REQUEST, 'missing parameters')
160 await updateAccount(a, a =>
161 saveSrpInfo(a, salt, verifier) )
162 delete a.require_password_change
163 return {}
164 }
165
166 } as const satisfies ApiHandlers