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