mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Refactor authentication code; add OIDC support
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import passport from 'passport'
|
||||
import Settings from '@overleaf/settings'
|
||||
import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js'
|
||||
import UserController from '../../../../../app/src/Features/User/UserController.js'
|
||||
import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
|
||||
import OIDCAuthenticationManager from './OIDCAuthenticationManager.mjs'
|
||||
import { acceptsJson } from '../../../../../app/src/infrastructure/RequestContentTypeDetection.js'
|
||||
|
||||
const OIDCAuthenticationController = {
|
||||
passportLogin(req, res, next) {
|
||||
req.session.intent = req.query.intent
|
||||
passport.authenticate('openidconnect')(req, res, next)
|
||||
},
|
||||
passportLoginCallback(req, res, next) {
|
||||
passport.authenticate(
|
||||
'openidconnect',
|
||||
{ keepSessionInfo: true },
|
||||
async function (err, user, info) {
|
||||
if (err) {
|
||||
return next(err)
|
||||
}
|
||||
if(req.session.intent === 'link') {
|
||||
delete req.session.intent
|
||||
// After linking, log out from the OIDC provider and redirect back to '/user/settings'.
|
||||
// Keycloak supports this; Authentik does not (yet).
|
||||
const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL
|
||||
const redirectUri = `${Settings.siteUrl.replace(/\/+$/, '')}/user/settings`
|
||||
return res.redirect(`${logoutUrl}?id_token_hint=${info.idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`)
|
||||
}
|
||||
if (user) {
|
||||
req.session.idToken = info.idToken
|
||||
user.externalAuth = 'oidc'
|
||||
// `user` is either a user object or false
|
||||
AuthenticationController.setAuditInfo(req, {
|
||||
method: 'OIDC login',
|
||||
})
|
||||
try {
|
||||
await AuthenticationController.promises.finishLogin(user, req, res)
|
||||
} catch (err) {
|
||||
return next(err)
|
||||
}
|
||||
} else {
|
||||
if (info.redir != null) {
|
||||
return res.json({ redir: info.redir })
|
||||
} else {
|
||||
res.status(info.status || 401)
|
||||
delete info.status
|
||||
const body = { message: info }
|
||||
return res.json(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
)(req, res, next)
|
||||
},
|
||||
async doPassportLogin(req, issuer, profile, context, idToken, accessToken, refreshToken, done) {
|
||||
let user, info
|
||||
try {
|
||||
if(req.session.intent === 'link') {
|
||||
;({ user, info } = await OIDCAuthenticationController._doLink(
|
||||
req,
|
||||
profile
|
||||
))
|
||||
} else {
|
||||
;({ user, info } = await OIDCAuthenticationController._doLogin(
|
||||
req,
|
||||
profile
|
||||
))
|
||||
}
|
||||
} catch (error) {
|
||||
return done(error)
|
||||
}
|
||||
if (user) {
|
||||
info = {
|
||||
...(info || {}),
|
||||
idToken: idToken
|
||||
}
|
||||
}
|
||||
return done(null, user, info)
|
||||
},
|
||||
async _doLogin(req, profile) {
|
||||
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
|
||||
const auditLog = {
|
||||
ipAddress: req.ip,
|
||||
info: { method: 'OIDC login', fromKnownDevice },
|
||||
}
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await OIDCAuthenticationManager.promises.findOrCreateUser(profile, auditLog)
|
||||
} catch (error) {
|
||||
logger.debug({ email : profile.emails[0].value }, `OIDC login failed: ${error}`)
|
||||
return {
|
||||
user: false,
|
||||
info: {
|
||||
type: 'error',
|
||||
text: error.message,
|
||||
status: 401,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (user) {
|
||||
return { user, info: undefined }
|
||||
} else { // we cannot be here, something is terribly wrong
|
||||
logger.debug({ email : profile.emails[0].value }, 'failed OIDC log in')
|
||||
return {
|
||||
user: false,
|
||||
info: {
|
||||
type: 'error',
|
||||
text: 'Unknown error',
|
||||
status: 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
async _doLink(req, profile) {
|
||||
const { user: { _id: userId }, ip } = req
|
||||
try {
|
||||
const auditLog = {
|
||||
ipAddress: ip,
|
||||
initiatorId: userId,
|
||||
}
|
||||
await OIDCAuthenticationManager.promises.linkAccount(userId, profile, auditLog)
|
||||
} catch (error) {
|
||||
logger.error(error.info, error.message)
|
||||
return {
|
||||
user: true,
|
||||
info: {
|
||||
type: 'error',
|
||||
text: error.message,
|
||||
status: 200,
|
||||
},
|
||||
}
|
||||
}
|
||||
return { user: true, info: undefined }
|
||||
},
|
||||
async unlinkAccount(req, res, next) {
|
||||
try {
|
||||
const { user: { _id: userId }, body: { providerId }, ip } = req
|
||||
const auditLog = {
|
||||
ipAddress: ip,
|
||||
initiatorId: userId,
|
||||
}
|
||||
await ThirdPartyIdentityManager.promises.unlink(userId, providerId, auditLog)
|
||||
return res.status(200).end()
|
||||
} catch (error) {
|
||||
logger.error(error.info, error.message)
|
||||
return {
|
||||
user: false,
|
||||
info: {
|
||||
type: 'error',
|
||||
text: 'Can not unlink account',
|
||||
status: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async passportLogout(req, res, next) {
|
||||
// TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken?
|
||||
const idTokenHint = req.session.idToken
|
||||
await UserController.promises.doLogout(req)
|
||||
const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL
|
||||
const redirectUri = Settings.siteUrl
|
||||
res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`)
|
||||
},
|
||||
passportLogoutCallback(req, res, next) {
|
||||
const redirectUri = Settings.siteUrl
|
||||
res.redirect(redirectUri)
|
||||
},
|
||||
}
|
||||
export default OIDCAuthenticationController
|
||||
@@ -0,0 +1,94 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import UserCreator from '../../../../../app/src/Features/User/UserCreator.js'
|
||||
import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js'
|
||||
import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js'
|
||||
import { User } from '../../../../../app/src/models/User.js'
|
||||
|
||||
const OIDCAuthenticationManager = {
|
||||
async findOrCreateUser(profile, auditLog) {
|
||||
const {
|
||||
attUserId,
|
||||
attAdmin,
|
||||
valAdmin,
|
||||
updateUserDetailsOnLogin,
|
||||
providerId,
|
||||
} = Settings.oidc
|
||||
const oidcUserId = profile[attUserId]
|
||||
const email = profile.emails[0].value
|
||||
const firstName = profile.name?.givenName || ""
|
||||
const lastName = profile.name?.familyName || ""
|
||||
let isAdmin = false
|
||||
if (attAdmin && valAdmin) {
|
||||
if (attAdmin === 'email') {
|
||||
isAdmin = (email === valAdmin)
|
||||
} else {
|
||||
isAdmin = (profile[attAdmin] === valAdmin)
|
||||
}
|
||||
}
|
||||
const oidcUserData = null // Possibly it can be used later
|
||||
let user
|
||||
try {
|
||||
user = await ThirdPartyIdentityManager.promises.login(providerId, oidcUserId, oidcUserData)
|
||||
} catch {
|
||||
// A user with the specified OIDC ID and provider ID is not found. Search for a user with the given email.
|
||||
// If no user exists with this email, create a new user and link the OIDC account to it.
|
||||
// If a user exists but no account from the specified OIDC provider is linked to this user, link the OIDC account to this user.
|
||||
// If an account from the specified provider is already linked to this user, unlink it, and link the OIDC account to this user.
|
||||
// (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error)
|
||||
user = await User.findOne({ 'email': email }).exec()
|
||||
if (!user) {
|
||||
user = await UserCreator.promises.createNewUser(
|
||||
{
|
||||
email: email,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
isAdmin: isAdmin,
|
||||
holdingAccount: false,
|
||||
}
|
||||
)
|
||||
}
|
||||
// const alreadyLinked = user.thirdPartyIdentifiers.some(item => item.providerId === providerId)
|
||||
// if (!alreadyLinked) {
|
||||
auditLog.initiatorId = user._id
|
||||
await ThirdPartyIdentityManager.promises.link(user._id, providerId, oidcUserId, oidcUserData, auditLog)
|
||||
await User.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set : {
|
||||
'emails.0.confirmedAt': Date.now(), //email of external user is confirmed
|
||||
},
|
||||
}
|
||||
).exec()
|
||||
// } else {
|
||||
// throw new Error(`Overleaf user ${user.email} is already linked to another ${providerId} user`)
|
||||
// }
|
||||
}
|
||||
|
||||
let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {}
|
||||
if (attAdmin && valAdmin) {
|
||||
user.isAdmin = isAdmin
|
||||
userDetails.isAdmin = isAdmin
|
||||
}
|
||||
const result = await User.updateOne(
|
||||
{ _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails },
|
||||
{}
|
||||
).exec()
|
||||
|
||||
if (result.modifiedCount !== 1) {
|
||||
throw new ParallelLoginError()
|
||||
}
|
||||
return user
|
||||
},
|
||||
async linkAccount(userId, profile, auditLog) {
|
||||
const {
|
||||
attUserId,
|
||||
providerId,
|
||||
} = Settings.oidc
|
||||
const oidcUserId = profile[attUserId]
|
||||
const oidcUserData = null // Possibly it can be used later
|
||||
await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog)
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
promises: OIDCAuthenticationManager,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import passport from 'passport'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs'
|
||||
import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js'
|
||||
import OIDCAuthenticationController from './OIDCAuthenticationController.mjs'
|
||||
import { Strategy as OIDCStrategy } from 'passport-openidconnect'
|
||||
|
||||
const OIDCModuleManager = {
|
||||
initSettings() {
|
||||
let providerId = process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc'
|
||||
Settings.oidc = {
|
||||
enable: true,
|
||||
providerId: providerId,
|
||||
identityServiceName: process.env.OVERLEAF_OIDC_IDENTITY_SERVICE_NAME || `Log in with ${Settings.oauthProviders[providerId].name}`,
|
||||
attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id',
|
||||
attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD,
|
||||
valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE,
|
||||
updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN),
|
||||
}
|
||||
},
|
||||
passportSetup(passport, callback) {
|
||||
const oidcOptions = {
|
||||
issuer: process.env.OVERLEAF_OIDC_ISSUER,
|
||||
authorizationURL: process.env.OVERLEAF_OIDC_AUTHORIZATION_URL,
|
||||
tokenURL: process.env.OVERLEAF_OIDC_TOKEN_URL,
|
||||
userInfoURL: process.env.OVERLEAF_OIDC_USER_INFO_URL,
|
||||
clientID: process.env.OVERLEAF_OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OVERLEAF_OIDC_CLIENT_SECRET,
|
||||
callbackURL: `${Settings.siteUrl.replace(/\/+$/, '')}/oidc/login/callback`,
|
||||
scope: process.env.OVERLEAF_OIDC_SCOPE || 'openid profile email',
|
||||
passReqToCallback: true,
|
||||
}
|
||||
try {
|
||||
passport.use(
|
||||
new OIDCStrategy(
|
||||
oidcOptions,
|
||||
OIDCAuthenticationController.doPassportLogin
|
||||
)
|
||||
)
|
||||
callback(null)
|
||||
} catch (error) {
|
||||
callback(error)
|
||||
}
|
||||
},
|
||||
initPolicy() {
|
||||
try {
|
||||
PermissionsManager.registerCapability('change-password', { default : true })
|
||||
} catch (error) {
|
||||
logger.info({}, error.message)
|
||||
}
|
||||
const oidcPolicyValidator = async ({ user, subscription }) => {
|
||||
// If user is not logged in, user.externalAuth is undefined,
|
||||
// in this case allow to change password if the user has a hashedPassword
|
||||
return user.externalAuth === 'oidc' || (user.externalAuth === undefined && !user.hashedPassword)
|
||||
}
|
||||
try {
|
||||
PermissionsManager.registerPolicy(
|
||||
'oidcPolicy',
|
||||
{ 'change-password' : false },
|
||||
{ validator: oidcPolicyValidator }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.info({}, error.message)
|
||||
}
|
||||
},
|
||||
async getGroupPolicyForUser(user, callback) {
|
||||
try {
|
||||
const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({
|
||||
user,
|
||||
groupPolicy : { 'oidcPolicy' : true },
|
||||
subscription : null
|
||||
})
|
||||
let groupPolicy = Object.fromEntries(userValidationMap)
|
||||
callback(null, {'groupPolicy' : groupPolicy })
|
||||
} catch (error) {
|
||||
callback(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default OIDCModuleManager
|
||||
@@ -0,0 +1,15 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import UserController from '../../../../../app/src/Features/User/UserController.js'
|
||||
import OIDCAuthenticationController from './OIDCAuthenticationController.mjs'
|
||||
import logout from '../../../logout.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init OIDC router')
|
||||
webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin)
|
||||
webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback)
|
||||
webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback)
|
||||
webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount)
|
||||
webRouter.post('/logout', logout, UserController.logout)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
let oidcModule = {}
|
||||
if (process.env.EXTERNAL_AUTH.includes('oidc')) {
|
||||
const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs')
|
||||
const { default: router } = await import('./app/src/OIDCRouter.mjs')
|
||||
OIDCModuleManager.initSettings()
|
||||
OIDCModuleManager.initPolicy()
|
||||
oidcModule = {
|
||||
name: 'oidc-authentication',
|
||||
hooks: {
|
||||
passportSetup: OIDCModuleManager.passportSetup,
|
||||
getGroupPolicyForUser: OIDCModuleManager.getGroupPolicyForUser,
|
||||
},
|
||||
router: router,
|
||||
}
|
||||
}
|
||||
export default oidcModule
|
||||
Reference in New Issue
Block a user