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.ts245
-rw-r--r--server/lib/auth/oauth.ts180
-rw-r--r--server/lib/auth/tokens-cache.ts52
4 files changed, 696 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..c74869ee2
--- /dev/null
+++ b/server/lib/auth/oauth-model.ts
@@ -0,0 +1,245 @@
1import { AccessDeniedError } from 'oauth2-server'
2import { PluginManager } from '@server/lib/plugins/plugin-manager'
3import { ActorModel } from '@server/models/activitypub/actor'
4import { MOAuthClient } from '@server/types/models'
5import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
6import { MUser } from '@server/types/models/user/user'
7import { UserAdminFlag } from '@shared/models/users/user-flag.model'
8import { UserRole } from '@shared/models/users/user-role'
9import { logger } from '../../helpers/logger'
10import { CONFIG } from '../../initializers/config'
11import { UserModel } from '../../models/account/user'
12import { OAuthClientModel } from '../../models/oauth/oauth-client'
13import { OAuthTokenModel } from '../../models/oauth/oauth-token'
14import { createUserAccountAndChannelAndPlaylist } from '../user'
15import { TokensCache } from './tokens-cache'
16
17type TokenInfo = {
18 accessToken: string
19 refreshToken: string
20 accessTokenExpiresAt: Date
21 refreshTokenExpiresAt: Date
22}
23
24export type BypassLogin = {
25 bypass: boolean
26 pluginName: string
27 authName?: string
28 user: {
29 username: string
30 email: string
31 displayName: string
32 role: UserRole
33 }
34}
35
36async function getAccessToken (bearerToken: string) {
37 logger.debug('Getting access token (bearerToken: ' + bearerToken + ').')
38
39 if (!bearerToken) return undefined
40
41 let tokenModel: MOAuthTokenUser
42
43 if (TokensCache.Instance.hasToken(bearerToken)) {
44 tokenModel = TokensCache.Instance.getByToken(bearerToken)
45 } else {
46 tokenModel = await OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
47
48 if (tokenModel) TokensCache.Instance.setToken(tokenModel)
49 }
50
51 if (!tokenModel) return undefined
52
53 if (tokenModel.User.pluginAuth) {
54 const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'access')
55
56 if (valid !== true) return undefined
57 }
58
59 return tokenModel
60}
61
62function getClient (clientId: string, clientSecret: string) {
63 logger.debug('Getting Client (clientId: ' + clientId + ', clientSecret: ' + clientSecret + ').')
64
65 return OAuthClientModel.getByIdAndSecret(clientId, clientSecret)
66}
67
68async function getRefreshToken (refreshToken: string) {
69 logger.debug('Getting RefreshToken (refreshToken: ' + refreshToken + ').')
70
71 const tokenInfo = await OAuthTokenModel.getByRefreshTokenAndPopulateClient(refreshToken)
72 if (!tokenInfo) return undefined
73
74 const tokenModel = tokenInfo.token
75
76 if (tokenModel.User.pluginAuth) {
77 const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'refresh')
78
79 if (valid !== true) return undefined
80 }
81
82 return tokenInfo
83}
84
85async function getUser (usernameOrEmail?: string, password?: string, bypassLogin?: BypassLogin) {
86 // Special treatment coming from a plugin
87 if (bypassLogin && bypassLogin.bypass === true) {
88 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
89
90 let user = await UserModel.loadByEmail(bypassLogin.user.email)
91 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
92
93 // Cannot create a user
94 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
95
96 // If the user does not belongs to a plugin, it was created before its installation
97 // Then we just go through a regular login process
98 if (user.pluginAuth !== null) {
99 // This user does not belong to this plugin, skip it
100 if (user.pluginAuth !== bypassLogin.pluginName) return null
101
102 checkUserValidityOrThrow(user)
103
104 return user
105 }
106 }
107
108 logger.debug('Getting User (username/email: ' + usernameOrEmail + ', password: ******).')
109
110 const user = await UserModel.loadByUsernameOrEmail(usernameOrEmail)
111 // If we don't find the user, or if the user belongs to a plugin
112 if (!user || user.pluginAuth !== null || !password) return null
113
114 const passwordMatch = await user.isPasswordMatch(password)
115 if (passwordMatch !== true) return null
116
117 checkUserValidityOrThrow(user)
118
119 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION && user.emailVerified === false) {
120 throw new AccessDeniedError('User email is not verified.')
121 }
122
123 return user
124}
125
126async function revokeToken (
127 tokenInfo: { refreshToken: string },
128 explicitLogout?: boolean
129): Promise<{ success: boolean, redirectUrl?: string }> {
130 const token = await OAuthTokenModel.getByRefreshTokenAndPopulateUser(tokenInfo.refreshToken)
131
132 if (token) {
133 let redirectUrl: string
134
135 if (explicitLogout === true && token.User.pluginAuth && token.authName) {
136 redirectUrl = await PluginManager.Instance.onLogout(token.User.pluginAuth, token.authName, token.User, this.request)
137 }
138
139 TokensCache.Instance.clearCacheByToken(token.accessToken)
140
141 token.destroy()
142 .catch(err => logger.error('Cannot destroy token when revoking token.', { err }))
143
144 return { success: true, redirectUrl }
145 }
146
147 return { success: false }
148}
149
150async function saveToken (
151 token: TokenInfo,
152 client: MOAuthClient,
153 user: MUser,
154 options: {
155 refreshTokenAuthName?: string
156 bypassLogin?: BypassLogin
157 } = {}
158) {
159 const { refreshTokenAuthName, bypassLogin } = options
160 let authName: string = null
161
162 if (bypassLogin?.bypass === true) {
163 authName = bypassLogin.authName
164 } else if (refreshTokenAuthName) {
165 authName = refreshTokenAuthName
166 }
167
168 logger.debug('Saving token ' + token.accessToken + ' for client ' + client.id + ' and user ' + user.id + '.')
169
170 const tokenToCreate = {
171 accessToken: token.accessToken,
172 accessTokenExpiresAt: token.accessTokenExpiresAt,
173 refreshToken: token.refreshToken,
174 refreshTokenExpiresAt: token.refreshTokenExpiresAt,
175 authName,
176 oAuthClientId: client.id,
177 userId: user.id
178 }
179
180 const tokenCreated = await OAuthTokenModel.create(tokenToCreate)
181
182 user.lastLoginDate = new Date()
183 await user.save()
184
185 return {
186 accessToken: tokenCreated.accessToken,
187 accessTokenExpiresAt: tokenCreated.accessTokenExpiresAt,
188 refreshToken: tokenCreated.refreshToken,
189 refreshTokenExpiresAt: tokenCreated.refreshTokenExpiresAt,
190 client,
191 user,
192 accessTokenExpiresIn: buildExpiresIn(tokenCreated.accessTokenExpiresAt),
193 refreshTokenExpiresIn: buildExpiresIn(tokenCreated.refreshTokenExpiresAt)
194 }
195}
196
197export {
198 getAccessToken,
199 getClient,
200 getRefreshToken,
201 getUser,
202 revokeToken,
203 saveToken
204}
205
206// ---------------------------------------------------------------------------
207
208async function createUserFromExternal (pluginAuth: string, options: {
209 username: string
210 email: string
211 role: UserRole
212 displayName: string
213}) {
214 // Check an actor does not already exists with that name (removed user)
215 const actor = await ActorModel.loadLocalByName(options.username)
216 if (actor) return null
217
218 const userToCreate = new UserModel({
219 username: options.username,
220 password: null,
221 email: options.email,
222 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
223 autoPlayVideo: true,
224 role: options.role,
225 videoQuota: CONFIG.USER.VIDEO_QUOTA,
226 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY,
227 adminFlags: UserAdminFlag.NONE,
228 pluginAuth
229 }) as MUser
230
231 const { user } = await createUserAccountAndChannelAndPlaylist({
232 userToCreate,
233 userDisplayName: options.displayName
234 })
235
236 return user
237}
238
239function checkUserValidityOrThrow (user: MUser) {
240 if (user.blocked) throw new AccessDeniedError('User is blocked.')
241}
242
243function buildExpiresIn (expiresAt: Date) {
244 return Math.floor((expiresAt.getTime() - new Date().getTime()) / 1000)
245}
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}