aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib/auth
diff options
context:
space:
mode:
Diffstat (limited to 'server/lib/auth')
-rw-r--r--server/lib/auth/external-auth.ts219
-rw-r--r--server/lib/auth/oauth-model.ts251
-rw-r--r--server/lib/auth/oauth.ts180
-rw-r--r--server/lib/auth/tokens-cache.ts52
4 files changed, 702 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
2import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users'
3import { logger } from '@server/helpers/logger'
4import { generateRandomString } from '@server/helpers/utils'
5import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
6import { PluginManager } from '@server/lib/plugins/plugin-manager'
7import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
8import {
9 RegisterServerAuthenticatedResult,
10 RegisterServerAuthPassOptions,
11 RegisterServerExternalAuthenticatedResult
12} from '@server/types/plugins/register-server-auth.model'
13import { UserRole } from '@shared/models'
14
15// Token is the key, expiration date is the value
16const 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
28async 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
73async function getAuthNameFromRefreshGrant (refreshToken?: string) {
74 if (!refreshToken) return undefined
75
76 const tokenModel = await OAuthTokenModel.loadByRefreshToken(refreshToken)
77
78 return tokenModel?.authName
79}
80
81async 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
146function 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
174function 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
203function 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
214export {
215 onExternalUserAuthenticated,
216 getBypassFromExternalAuth,
217 getAuthNameFromRefreshGrant,
218 getBypassFromPasswordGrant
219}
diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts
new file mode 100644
index 000000000..b9c69eb2d
--- /dev/null
+++ b/server/lib/auth/oauth-model.ts
@@ -0,0 +1,251 @@
1import * as express from 'express'
2import { AccessDeniedError } from 'oauth2-server'
3import { PluginManager } from '@server/lib/plugins/plugin-manager'
4import { ActorModel } from '@server/models/activitypub/actor'
5import { MOAuthClient } from '@server/types/models'
6import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
7import { MUser } from '@server/types/models/user/user'
8import { UserAdminFlag } from '@shared/models/users/user-flag.model'
9import { UserRole } from '@shared/models/users/user-role'
10import { logger } from '../../helpers/logger'
11import { CONFIG } from '../../initializers/config'
12import { UserModel } from '../../models/account/user'
13import { OAuthClientModel } from '../../models/oauth/oauth-client'
14import { OAuthTokenModel } from '../../models/oauth/oauth-token'
15import { createUserAccountAndChannelAndPlaylist } from '../user'
16import { TokensCache } from './tokens-cache'
17
18type TokenInfo = {
19 accessToken: string
20 refreshToken: string
21 accessTokenExpiresAt: Date
22 refreshTokenExpiresAt: Date
23}
24
25export type BypassLogin = {
26 bypass: boolean
27 pluginName: string
28 authName?: string
29 user: {
30 username: string
31 email: string
32 displayName: string
33 role: UserRole
34 }
35}
36
37async function getAccessToken (bearerToken: string) {
38 logger.debug('Getting access token (bearerToken: ' + bearerToken + ').')
39
40 if (!bearerToken) return undefined
41
42 let tokenModel: MOAuthTokenUser
43
44 if (TokensCache.Instance.hasToken(bearerToken)) {
45 tokenModel = TokensCache.Instance.getByToken(bearerToken)
46 } else {
47 tokenModel = await OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
48
49 if (tokenModel) TokensCache.Instance.setToken(tokenModel)
50 }
51
52 if (!tokenModel) return undefined
53
54 if (tokenModel.User.pluginAuth) {
55 const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'access')
56
57 if (valid !== true) return undefined
58 }
59
60 return tokenModel
61}
62
63function getClient (clientId: string, clientSecret: string) {
64 logger.debug('Getting Client (clientId: ' + clientId + ', clientSecret: ' + clientSecret + ').')
65
66 return OAuthClientModel.getByIdAndSecret(clientId, clientSecret)
67}
68
69async function getRefreshToken (refreshToken: string) {
70 logger.debug('Getting RefreshToken (refreshToken: ' + refreshToken + ').')
71
72 const tokenInfo = await OAuthTokenModel.getByRefreshTokenAndPopulateClient(refreshToken)
73 if (!tokenInfo) return undefined
74
75 const tokenModel = tokenInfo.token
76
77 if (tokenModel.User.pluginAuth) {
78 const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'refresh')
79
80 if (valid !== true) return undefined
81 }
82
83 return tokenInfo
84}
85
86async function getUser (usernameOrEmail?: string, password?: string, bypassLogin?: BypassLogin) {
87 // Special treatment coming from a plugin
88 if (bypassLogin && bypassLogin.bypass === true) {
89 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
90
91 let user = await UserModel.loadByEmail(bypassLogin.user.email)
92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
93
94 // Cannot create a user
95 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
96
97 // If the user does not belongs to a plugin, it was created before its installation
98 // Then we just go through a regular login process
99 if (user.pluginAuth !== null) {
100 // This user does not belong to this plugin, skip it
101 if (user.pluginAuth !== bypassLogin.pluginName) return null
102
103 checkUserValidityOrThrow(user)
104
105 return user
106 }
107 }
108
109 logger.debug('Getting User (username/email: ' + usernameOrEmail + ', password: ******).')
110
111 const user = await UserModel.loadByUsernameOrEmail(usernameOrEmail)
112 // If we don't find the user, or if the user belongs to a plugin
113 if (!user || user.pluginAuth !== null || !password) return null
114
115 const passwordMatch = await user.isPasswordMatch(password)
116 if (passwordMatch !== true) return null
117
118 checkUserValidityOrThrow(user)
119
120 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION && user.emailVerified === false) {
121 throw new AccessDeniedError('User email is not verified.')
122 }
123
124 return user
125}
126
127async function revokeToken (
128 tokenInfo: { refreshToken: string },
129 options: {
130 req?: express.Request
131 explicitLogout?: boolean
132 } = {}
133): Promise<{ success: boolean, redirectUrl?: string }> {
134 const { req, explicitLogout } = options
135
136 const token = await OAuthTokenModel.getByRefreshTokenAndPopulateUser(tokenInfo.refreshToken)
137
138 if (token) {
139 let redirectUrl: string
140
141 if (explicitLogout === true && token.User.pluginAuth && token.authName) {
142 redirectUrl = await PluginManager.Instance.onLogout(token.User.pluginAuth, token.authName, token.User, req)
143 }
144
145 TokensCache.Instance.clearCacheByToken(token.accessToken)
146
147 token.destroy()
148 .catch(err => logger.error('Cannot destroy token when revoking token.', { err }))
149
150 return { success: true, redirectUrl }
151 }
152
153 return { success: false }
154}
155
156async function saveToken (
157 token: TokenInfo,
158 client: MOAuthClient,
159 user: MUser,
160 options: {
161 refreshTokenAuthName?: string
162 bypassLogin?: BypassLogin
163 } = {}
164) {
165 const { refreshTokenAuthName, bypassLogin } = options
166 let authName: string = null
167
168 if (bypassLogin?.bypass === true) {
169 authName = bypassLogin.authName
170 } else if (refreshTokenAuthName) {
171 authName = refreshTokenAuthName
172 }
173
174 logger.debug('Saving token ' + token.accessToken + ' for client ' + client.id + ' and user ' + user.id + '.')
175
176 const tokenToCreate = {
177 accessToken: token.accessToken,
178 accessTokenExpiresAt: token.accessTokenExpiresAt,
179 refreshToken: token.refreshToken,
180 refreshTokenExpiresAt: token.refreshTokenExpiresAt,
181 authName,
182 oAuthClientId: client.id,
183 userId: user.id
184 }
185
186 const tokenCreated = await OAuthTokenModel.create(tokenToCreate)
187
188 user.lastLoginDate = new Date()
189 await user.save()
190
191 return {
192 accessToken: tokenCreated.accessToken,
193 accessTokenExpiresAt: tokenCreated.accessTokenExpiresAt,
194 refreshToken: tokenCreated.refreshToken,
195 refreshTokenExpiresAt: tokenCreated.refreshTokenExpiresAt,
196 client,
197 user,
198 accessTokenExpiresIn: buildExpiresIn(tokenCreated.accessTokenExpiresAt),
199 refreshTokenExpiresIn: buildExpiresIn(tokenCreated.refreshTokenExpiresAt)
200 }
201}
202
203export {
204 getAccessToken,
205 getClient,
206 getRefreshToken,
207 getUser,
208 revokeToken,
209 saveToken
210}
211
212// ---------------------------------------------------------------------------
213
214async function createUserFromExternal (pluginAuth: string, options: {
215 username: string
216 email: string
217 role: UserRole
218 displayName: string
219}) {
220 // Check an actor does not already exists with that name (removed user)
221 const actor = await ActorModel.loadLocalByName(options.username)
222 if (actor) return null
223
224 const userToCreate = new UserModel({
225 username: options.username,
226 password: null,
227 email: options.email,
228 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
229 autoPlayVideo: true,
230 role: options.role,
231 videoQuota: CONFIG.USER.VIDEO_QUOTA,
232 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY,
233 adminFlags: UserAdminFlag.NONE,
234 pluginAuth
235 }) as MUser
236
237 const { user } = await createUserAccountAndChannelAndPlaylist({
238 userToCreate,
239 userDisplayName: options.displayName
240 })
241
242 return user
243}
244
245function checkUserValidityOrThrow (user: MUser) {
246 if (user.blocked) throw new AccessDeniedError('User is blocked.')
247}
248
249function buildExpiresIn (expiresAt: Date) {
250 return Math.floor((expiresAt.getTime() - new Date().getTime()) / 1000)
251}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
new file mode 100644
index 000000000..5b6130d56
--- /dev/null
+++ b/server/lib/auth/oauth.ts
@@ -0,0 +1,180 @@
1import * as express from 'express'
2import {
3 InvalidClientError,
4 InvalidGrantError,
5 InvalidRequestError,
6 Request,
7 Response,
8 UnauthorizedClientError,
9 UnsupportedGrantTypeError
10} from 'oauth2-server'
11import { randomBytesPromise, sha1 } from '@server/helpers/core-utils'
12import { MOAuthClient } from '@server/types/models'
13import { OAUTH_LIFETIME } from '../../initializers/constants'
14import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
15
16/**
17 *
18 * Reimplement some functions of OAuth2Server to inject external auth methods
19 *
20 */
21
22const oAuthServer = new (require('oauth2-server'))({
23 accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN,
24 refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN,
25
26 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
27 model: require('./oauth-model')
28})
29
30// ---------------------------------------------------------------------------
31
32async function handleOAuthToken (req: express.Request, options: { refreshTokenAuthName?: string, bypassLogin?: BypassLogin }) {
33 const request = new Request(req)
34 const { refreshTokenAuthName, bypassLogin } = options
35
36 if (request.method !== 'POST') {
37 throw new InvalidRequestError('Invalid request: method must be POST')
38 }
39
40 if (!request.is([ 'application/x-www-form-urlencoded' ])) {
41 throw new InvalidRequestError('Invalid request: content must be application/x-www-form-urlencoded')
42 }
43
44 const clientId = request.body.client_id
45 const clientSecret = request.body.client_secret
46
47 if (!clientId || !clientSecret) {
48 throw new InvalidClientError('Invalid client: cannot retrieve client credentials')
49 }
50
51 const client = await getClient(clientId, clientSecret)
52 if (!client) {
53 throw new InvalidClientError('Invalid client: client is invalid')
54 }
55
56 const grantType = request.body.grant_type
57 if (!grantType) {
58 throw new InvalidRequestError('Missing parameter: `grant_type`')
59 }
60
61 if (![ 'password', 'refresh_token' ].includes(grantType)) {
62 throw new UnsupportedGrantTypeError('Unsupported grant type: `grant_type` is invalid')
63 }
64
65 if (!client.grants.includes(grantType)) {
66 throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid')
67 }
68
69 if (grantType === 'password') {
70 return handlePasswordGrant({
71 request,
72 client,
73 bypassLogin
74 })
75 }
76
77 return handleRefreshGrant({
78 request,
79 client,
80 refreshTokenAuthName
81 })
82}
83
84async function handleOAuthAuthenticate (
85 req: express.Request,
86 res: express.Response,
87 authenticateInQuery = false
88) {
89 const options = authenticateInQuery
90 ? { allowBearerTokensInQueryString: true }
91 : {}
92
93 return oAuthServer.authenticate(new Request(req), new Response(res), options)
94}
95
96export {
97 handleOAuthToken,
98 handleOAuthAuthenticate
99}
100
101// ---------------------------------------------------------------------------
102
103async function handlePasswordGrant (options: {
104 request: Request
105 client: MOAuthClient
106 bypassLogin?: BypassLogin
107}) {
108 const { request, client, bypassLogin } = options
109
110 if (!request.body.username) {
111 throw new InvalidRequestError('Missing parameter: `username`')
112 }
113
114 if (!bypassLogin && !request.body.password) {
115 throw new InvalidRequestError('Missing parameter: `password`')
116 }
117
118 const user = await getUser(request.body.username, request.body.password, bypassLogin)
119 if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid')
120
121 const token = await buildToken()
122
123 return saveToken(token, client, user, { bypassLogin })
124}
125
126async function handleRefreshGrant (options: {
127 request: Request
128 client: MOAuthClient
129 refreshTokenAuthName: string
130}) {
131 const { request, client, refreshTokenAuthName } = options
132
133 if (!request.body.refresh_token) {
134 throw new InvalidRequestError('Missing parameter: `refresh_token`')
135 }
136
137 const refreshToken = await getRefreshToken(request.body.refresh_token)
138
139 if (!refreshToken) {
140 throw new InvalidGrantError('Invalid grant: refresh token is invalid')
141 }
142
143 if (refreshToken.client.id !== client.id) {
144 throw new InvalidGrantError('Invalid grant: refresh token is invalid')
145 }
146
147 if (refreshToken.refreshTokenExpiresAt && refreshToken.refreshTokenExpiresAt < new Date()) {
148 throw new InvalidGrantError('Invalid grant: refresh token has expired')
149 }
150
151 await revokeToken({ refreshToken: refreshToken.refreshToken })
152
153 const token = await buildToken()
154
155 return saveToken(token, client, refreshToken.user, { refreshTokenAuthName })
156}
157
158function generateRandomToken () {
159 return randomBytesPromise(256)
160 .then(buffer => sha1(buffer))
161}
162
163function getTokenExpiresAt (type: 'access' | 'refresh') {
164 const lifetime = type === 'access'
165 ? OAUTH_LIFETIME.ACCESS_TOKEN
166 : OAUTH_LIFETIME.REFRESH_TOKEN
167
168 return new Date(Date.now() + lifetime * 1000)
169}
170
171async function buildToken () {
172 const [ accessToken, refreshToken ] = await Promise.all([ generateRandomToken(), generateRandomToken() ])
173
174 return {
175 accessToken,
176 refreshToken,
177 accessTokenExpiresAt: getTokenExpiresAt('access'),
178 refreshTokenExpiresAt: getTokenExpiresAt('refresh')
179 }
180}
diff --git a/server/lib/auth/tokens-cache.ts b/server/lib/auth/tokens-cache.ts
new file mode 100644
index 000000000..b027ce69a
--- /dev/null
+++ b/server/lib/auth/tokens-cache.ts
@@ -0,0 +1,52 @@
1import * as LRUCache from 'lru-cache'
2import { MOAuthTokenUser } from '@server/types/models'
3import { LRU_CACHE } from '../../initializers/constants'
4
5export class TokensCache {
6
7 private static instance: TokensCache
8
9 private readonly accessTokenCache = new LRUCache<string, MOAuthTokenUser>({ max: LRU_CACHE.USER_TOKENS.MAX_SIZE })
10 private readonly userHavingToken = new LRUCache<number, string>({ max: LRU_CACHE.USER_TOKENS.MAX_SIZE })
11
12 private constructor () { }
13
14 static get Instance () {
15 return this.instance || (this.instance = new this())
16 }
17
18 hasToken (token: string) {
19 return this.accessTokenCache.has(token)
20 }
21
22 getByToken (token: string) {
23 return this.accessTokenCache.get(token)
24 }
25
26 setToken (token: MOAuthTokenUser) {
27 this.accessTokenCache.set(token.accessToken, token)
28 this.userHavingToken.set(token.userId, token.accessToken)
29 }
30
31 deleteUserToken (userId: number) {
32 this.clearCacheByUserId(userId)
33 }
34
35 clearCacheByUserId (userId: number) {
36 const token = this.userHavingToken.get(userId)
37
38 if (token !== undefined) {
39 this.accessTokenCache.del(token)
40 this.userHavingToken.del(userId)
41 }
42 }
43
44 clearCacheByToken (token: string) {
45 const tokenModel = this.accessTokenCache.get(token)
46
47 if (tokenModel !== undefined) {
48 this.userHavingToken.del(tokenModel.userId)
49 this.accessTokenCache.del(token)
50 }
51 }
52}