Begin support for external auths
[oweals/peertube.git] / server / lib / auth.ts
1 import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users'
2 import { logger } from '@server/helpers/logger'
3 import { generateRandomString } from '@server/helpers/utils'
4 import { OAUTH_LIFETIME, WEBSERVER } from '@server/initializers/constants'
5 import { revokeToken } from '@server/lib/oauth-model'
6 import { PluginManager } from '@server/lib/plugins/plugin-manager'
7 import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
8 import { UserRole } from '@shared/models'
9 import {
10   RegisterServerAuthenticatedResult,
11   RegisterServerAuthPassOptions,
12   RegisterServerExternalAuthenticatedResult
13 } from '@shared/models/plugins/register-server-auth.model'
14 import * as express from 'express'
15 import * as OAuthServer from 'express-oauth-server'
16
17 const oAuthServer = new OAuthServer({
18   useErrorHandler: true,
19   accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN,
20   refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN,
21   continueMiddleware: true,
22   model: require('./oauth-model')
23 })
24
25 // Token is the key, expiration date is the value
26 const authBypassTokens = new Map<string, {
27   expires: Date
28   user: {
29     username: string
30     email: string
31     displayName: string
32     role: UserRole
33   }
34   authName: string
35   npmName: string
36 }>()
37
38 async function handleIdAndPassLogin (req: express.Request, res: express.Response, next: express.NextFunction) {
39   const grantType = req.body.grant_type
40
41   if (grantType === 'password') {
42     if (req.body.externalAuthToken) proxifyExternalAuthBypass(req, res)
43     else await proxifyPasswordGrant(req, res)
44   } else if (grantType === 'refresh_token') {
45     await proxifyRefreshGrant(req, res)
46   }
47
48   return forwardTokenReq(req, res, next)
49 }
50
51 async function handleTokenRevocation (req: express.Request, res: express.Response) {
52   const token = res.locals.oauth.token
53
54   res.locals.explicitLogout = true
55   await revokeToken(token)
56
57   // FIXME: uncomment when https://github.com/oauthjs/node-oauth2-server/pull/289 is released
58   // oAuthServer.revoke(req, res, err => {
59   //   if (err) {
60   //     logger.warn('Error in revoke token handler.', { err })
61   //
62   //     return res.status(err.status)
63   //               .json({
64   //                 error: err.message,
65   //                 code: err.name
66   //               })
67   //               .end()
68   //   }
69   // })
70
71   return res.sendStatus(200)
72 }
73
74 async function onExternalUserAuthenticated (options: {
75   npmName: string
76   authName: string
77   authResult: RegisterServerExternalAuthenticatedResult
78 }) {
79   const { npmName, authName, authResult } = options
80
81   if (!authResult.req || !authResult.res) {
82     logger.error('Cannot authenticate external user for auth %s of plugin %s: no req or res are provided.', authName, npmName)
83     return
84   }
85
86   if (!isAuthResultValid(npmName, authName, authResult)) return
87
88   const { res } = authResult
89
90   logger.info('Generating auth bypass token for %s in auth %s of plugin %s.', authResult.username, authName, npmName)
91
92   const bypassToken = await generateRandomString(32)
93   const tokenLifetime = 1000 * 60 * 5 // 5 minutes
94
95   const expires = new Date()
96   expires.setTime(expires.getTime() + tokenLifetime)
97
98   const user = buildUserResult(authResult)
99   authBypassTokens.set(bypassToken, {
100     expires,
101     user,
102     npmName,
103     authName
104   })
105
106   res.redirect(`/login?externalAuthToken=${bypassToken}&username=${user.username}`)
107 }
108
109 // ---------------------------------------------------------------------------
110
111 export { oAuthServer, handleIdAndPassLogin, onExternalUserAuthenticated, handleTokenRevocation }
112
113 // ---------------------------------------------------------------------------
114
115 function forwardTokenReq (req: express.Request, res: express.Response, next?: express.NextFunction) {
116   return oAuthServer.token()(req, res, err => {
117     if (err) {
118       logger.warn('Login error.', { err })
119
120       return res.status(err.status)
121         .json({
122           error: err.message,
123           code: err.name
124         })
125     }
126
127     if (next) return next()
128   })
129 }
130
131 async function proxifyRefreshGrant (req: express.Request, res: express.Response) {
132   const refreshToken = req.body.refresh_token
133   if (!refreshToken) return
134
135   const tokenModel = await OAuthTokenModel.loadByRefreshToken(refreshToken)
136   if (tokenModel?.authName) res.locals.refreshTokenAuthName = tokenModel.authName
137 }
138
139 async function proxifyPasswordGrant (req: express.Request, res: express.Response) {
140   const plugins = PluginManager.Instance.getIdAndPassAuths()
141   const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
142
143   for (const plugin of plugins) {
144     const auths = plugin.idAndPassAuths
145
146     for (const auth of auths) {
147       pluginAuths.push({
148         npmName: plugin.npmName,
149         registerAuthOptions: auth
150       })
151     }
152   }
153
154   pluginAuths.sort((a, b) => {
155     const aWeight = a.registerAuthOptions.getWeight()
156     const bWeight = b.registerAuthOptions.getWeight()
157
158     // DESC weight order
159     if (aWeight === bWeight) return 0
160     if (aWeight < bWeight) return 1
161     return -1
162   })
163
164   const loginOptions = {
165     id: req.body.username,
166     password: req.body.password
167   }
168
169   for (const pluginAuth of pluginAuths) {
170     const authOptions = pluginAuth.registerAuthOptions
171     const authName = authOptions.authName
172     const npmName = pluginAuth.npmName
173
174     logger.debug(
175       'Using auth method %s of plugin %s to login %s with weight %d.',
176       authName, npmName, loginOptions.id, authOptions.getWeight()
177     )
178
179     try {
180       const loginResult = await authOptions.login(loginOptions)
181
182       if (!loginResult) continue
183       if (!isAuthResultValid(pluginAuth.npmName, authOptions.authName, loginResult)) continue
184
185       logger.info(
186         'Login success with auth method %s of plugin %s for %s.',
187         authName, npmName, loginOptions.id
188       )
189
190       res.locals.bypassLogin = {
191         bypass: true,
192         pluginName: pluginAuth.npmName,
193         authName: authOptions.authName,
194         user: buildUserResult(loginResult)
195       }
196
197       return
198     } catch (err) {
199       logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
200     }
201   }
202 }
203
204 function proxifyExternalAuthBypass (req: express.Request, res: express.Response) {
205   const obj = authBypassTokens.get(req.body.externalAuthToken)
206   if (!obj) {
207     logger.error('Cannot authenticate user with unknown bypass token')
208     return res.sendStatus(400)
209   }
210
211   const { expires, user, authName, npmName } = obj
212
213   const now = new Date()
214   if (now.getTime() > expires.getTime()) {
215     logger.error('Cannot authenticate user with an expired bypass token')
216     return res.sendStatus(400)
217   }
218
219   if (user.username !== req.body.username) {
220     logger.error('Cannot authenticate user %s with invalid username %s.', req.body.username)
221     return res.sendStatus(400)
222   }
223
224   // Bypass oauth library validation
225   req.body.password = 'fake'
226
227   logger.info(
228     'Auth success with external auth method %s of plugin %s for %s.',
229     authName, npmName, user.email
230   )
231
232   res.locals.bypassLogin = {
233     bypass: true,
234     pluginName: npmName,
235     authName: authName,
236     user
237   }
238 }
239
240 function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
241   if (!isUserUsernameValid(result.username)) {
242     logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { result })
243     return false
244   }
245
246   if (!result.email) {
247     logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { result })
248     return false
249   }
250
251   // role is optional
252   if (result.role && !isUserRoleValid(result.role)) {
253     logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { result })
254     return false
255   }
256
257   // display name is optional
258   if (result.displayName && !isUserDisplayNameValid(result.displayName)) {
259     logger.error('Auth method %s of plugin %s did not provide a valid display name.', authName, npmName, { result })
260     return false
261   }
262
263   return true
264 }
265
266 function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
267   return {
268     username: pluginResult.username,
269     email: pluginResult.email,
270     role: pluginResult.role || UserRole.USER,
271     displayName: pluginResult.displayName || pluginResult.username
272   }
273 }