diff options
Diffstat (limited to 'server/server/lib/auth/external-auth.ts')
-rw-r--r-- | server/server/lib/auth/external-auth.ts | 230 |
1 files changed, 230 insertions, 0 deletions
diff --git a/server/server/lib/auth/external-auth.ts b/server/server/lib/auth/external-auth.ts new file mode 100644 index 000000000..e1879aa01 --- /dev/null +++ b/server/server/lib/auth/external-auth.ts | |||
@@ -0,0 +1,230 @@ | |||
1 | import { | ||
2 | isUserAdminFlagsValid, | ||
3 | isUserDisplayNameValid, | ||
4 | isUserRoleValid, | ||
5 | isUserUsernameValid, | ||
6 | isUserVideoQuotaDailyValid, | ||
7 | isUserVideoQuotaValid | ||
8 | } from '@server/helpers/custom-validators/users.js' | ||
9 | import { logger } from '@server/helpers/logger.js' | ||
10 | import { generateRandomString } from '@server/helpers/utils.js' | ||
11 | import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants.js' | ||
12 | import { PluginManager } from '@server/lib/plugins/plugin-manager.js' | ||
13 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token.js' | ||
14 | import { MUser } from '@server/types/models/index.js' | ||
15 | import { | ||
16 | RegisterServerAuthenticatedResult, | ||
17 | RegisterServerAuthPassOptions, | ||
18 | RegisterServerExternalAuthenticatedResult | ||
19 | } from '@server/types/plugins/register-server-auth.model.js' | ||
20 | import { UserAdminFlag, UserRole } from '@peertube/peertube-models' | ||
21 | import { BypassLogin } from './oauth-model.js' | ||
22 | |||
23 | export type ExternalUser = | ||
24 | Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> & | ||
25 | { displayName: string } | ||
26 | |||
27 | // Token is the key, expiration date is the value | ||
28 | const authBypassTokens = new Map<string, { | ||
29 | expires: Date | ||
30 | user: ExternalUser | ||
31 | userUpdater: RegisterServerAuthenticatedResult['userUpdater'] | ||
32 | authName: string | ||
33 | npmName: string | ||
34 | }>() | ||
35 | |||
36 | async function onExternalUserAuthenticated (options: { | ||
37 | npmName: string | ||
38 | authName: string | ||
39 | authResult: RegisterServerExternalAuthenticatedResult | ||
40 | }) { | ||
41 | const { npmName, authName, authResult } = options | ||
42 | |||
43 | if (!authResult.req || !authResult.res) { | ||
44 | logger.error('Cannot authenticate external user for auth %s of plugin %s: no req or res are provided.', authName, npmName) | ||
45 | return | ||
46 | } | ||
47 | |||
48 | const { res } = authResult | ||
49 | |||
50 | if (!isAuthResultValid(npmName, authName, authResult)) { | ||
51 | res.redirect('/login?externalAuthError=true') | ||
52 | return | ||
53 | } | ||
54 | |||
55 | logger.info('Generating auth bypass token for %s in auth %s of plugin %s.', authResult.username, authName, npmName) | ||
56 | |||
57 | const bypassToken = await generateRandomString(32) | ||
58 | |||
59 | const expires = new Date() | ||
60 | expires.setTime(expires.getTime() + PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME) | ||
61 | |||
62 | const user = buildUserResult(authResult) | ||
63 | authBypassTokens.set(bypassToken, { | ||
64 | expires, | ||
65 | user, | ||
66 | npmName, | ||
67 | authName, | ||
68 | userUpdater: authResult.userUpdater | ||
69 | }) | ||
70 | |||
71 | // Cleanup expired tokens | ||
72 | const now = new Date() | ||
73 | for (const [ key, value ] of authBypassTokens) { | ||
74 | if (value.expires.getTime() < now.getTime()) { | ||
75 | authBypassTokens.delete(key) | ||
76 | } | ||
77 | } | ||
78 | |||
79 | res.redirect(`/login?externalAuthToken=${bypassToken}&username=${user.username}`) | ||
80 | } | ||
81 | |||
82 | async function getAuthNameFromRefreshGrant (refreshToken?: string) { | ||
83 | if (!refreshToken) return undefined | ||
84 | |||
85 | const tokenModel = await OAuthTokenModel.loadByRefreshToken(refreshToken) | ||
86 | |||
87 | return tokenModel?.authName | ||
88 | } | ||
89 | |||
90 | async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> { | ||
91 | const plugins = PluginManager.Instance.getIdAndPassAuths() | ||
92 | const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] | ||
93 | |||
94 | for (const plugin of plugins) { | ||
95 | const auths = plugin.idAndPassAuths | ||
96 | |||
97 | for (const auth of auths) { | ||
98 | pluginAuths.push({ | ||
99 | npmName: plugin.npmName, | ||
100 | registerAuthOptions: auth | ||
101 | }) | ||
102 | } | ||
103 | } | ||
104 | |||
105 | pluginAuths.sort((a, b) => { | ||
106 | const aWeight = a.registerAuthOptions.getWeight() | ||
107 | const bWeight = b.registerAuthOptions.getWeight() | ||
108 | |||
109 | // DESC weight order | ||
110 | if (aWeight === bWeight) return 0 | ||
111 | if (aWeight < bWeight) return 1 | ||
112 | return -1 | ||
113 | }) | ||
114 | |||
115 | const loginOptions = { | ||
116 | id: username, | ||
117 | password | ||
118 | } | ||
119 | |||
120 | for (const pluginAuth of pluginAuths) { | ||
121 | const authOptions = pluginAuth.registerAuthOptions | ||
122 | const authName = authOptions.authName | ||
123 | const npmName = pluginAuth.npmName | ||
124 | |||
125 | logger.debug( | ||
126 | 'Using auth method %s of plugin %s to login %s with weight %d.', | ||
127 | authName, npmName, loginOptions.id, authOptions.getWeight() | ||
128 | ) | ||
129 | |||
130 | try { | ||
131 | const loginResult = await authOptions.login(loginOptions) | ||
132 | |||
133 | if (!loginResult) continue | ||
134 | if (!isAuthResultValid(pluginAuth.npmName, authOptions.authName, loginResult)) continue | ||
135 | |||
136 | logger.info( | ||
137 | 'Login success with auth method %s of plugin %s for %s.', | ||
138 | authName, npmName, loginOptions.id | ||
139 | ) | ||
140 | |||
141 | return { | ||
142 | bypass: true, | ||
143 | pluginName: pluginAuth.npmName, | ||
144 | authName: authOptions.authName, | ||
145 | user: buildUserResult(loginResult), | ||
146 | userUpdater: loginResult.userUpdater | ||
147 | } | ||
148 | } catch (err) { | ||
149 | logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) | ||
150 | } | ||
151 | } | ||
152 | |||
153 | return undefined | ||
154 | } | ||
155 | |||
156 | function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin { | ||
157 | const obj = authBypassTokens.get(externalAuthToken) | ||
158 | if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') | ||
159 | |||
160 | const { expires, user, authName, npmName } = obj | ||
161 | |||
162 | const now = new Date() | ||
163 | if (now.getTime() > expires.getTime()) { | ||
164 | throw new Error('Cannot authenticate user with an expired external auth token') | ||
165 | } | ||
166 | |||
167 | if (user.username !== username) { | ||
168 | throw new Error(`Cannot authenticate user ${user.username} with invalid username ${username}`) | ||
169 | } | ||
170 | |||
171 | logger.info( | ||
172 | 'Auth success with external auth method %s of plugin %s for %s.', | ||
173 | authName, npmName, user.email | ||
174 | ) | ||
175 | |||
176 | return { | ||
177 | bypass: true, | ||
178 | pluginName: npmName, | ||
179 | authName, | ||
180 | userUpdater: obj.userUpdater, | ||
181 | user | ||
182 | } | ||
183 | } | ||
184 | |||
185 | function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { | ||
186 | const returnError = (field: string) => { | ||
187 | logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] }) | ||
188 | return false | ||
189 | } | ||
190 | |||
191 | if (!isUserUsernameValid(result.username)) return returnError('username') | ||
192 | if (!result.email) return returnError('email') | ||
193 | |||
194 | // Following fields are optional | ||
195 | if (result.role && !isUserRoleValid(result.role)) return returnError('role') | ||
196 | if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName') | ||
197 | if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags') | ||
198 | if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota') | ||
199 | if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily') | ||
200 | |||
201 | if (result.userUpdater && typeof result.userUpdater !== 'function') { | ||
202 | logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName) | ||
203 | return false | ||
204 | } | ||
205 | |||
206 | return true | ||
207 | } | ||
208 | |||
209 | function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) { | ||
210 | return { | ||
211 | username: pluginResult.username, | ||
212 | email: pluginResult.email, | ||
213 | role: pluginResult.role ?? UserRole.USER, | ||
214 | displayName: pluginResult.displayName || pluginResult.username, | ||
215 | |||
216 | adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE, | ||
217 | |||
218 | videoQuota: pluginResult.videoQuota, | ||
219 | videoQuotaDaily: pluginResult.videoQuotaDaily | ||
220 | } | ||
221 | } | ||
222 | |||
223 | // --------------------------------------------------------------------------- | ||
224 | |||
225 | export { | ||
226 | onExternalUserAuthenticated, | ||
227 | getBypassFromExternalAuth, | ||
228 | getAuthNameFromRefreshGrant, | ||
229 | getBypassFromPasswordGrant | ||
230 | } | ||