@openneuro/server 4.34.2 → 4.35.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/libs/authentication/google.ts +55 -5
- package/src/libs/authentication/jwt.ts +3 -3
- package/src/libs/authentication/orcid.ts +9 -10
- package/src/libs/authentication/passport.ts +1 -1
- package/src/libs/authentication/user-migration.ts +121 -0
- package/src/models/user.ts +19 -0
- package/src/models/userMigration.ts +32 -0
- package/src/routes.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openneuro/server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.35.0-alpha.0",
|
|
4
4
|
"description": "Core service for the OpenNeuro platform.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"@elastic/elasticsearch": "8.13.1",
|
|
22
22
|
"@graphql-tools/schema": "^10.0.0",
|
|
23
23
|
"@keyv/redis": "^2.7.0",
|
|
24
|
-
"@openneuro/search": "^4.
|
|
24
|
+
"@openneuro/search": "^4.35.0-alpha.0",
|
|
25
25
|
"@sentry/node": "^8.25.0",
|
|
26
26
|
"@sentry/profiling-node": "^8.25.0",
|
|
27
27
|
"base64url": "^3.0.0",
|
|
@@ -85,5 +85,5 @@
|
|
|
85
85
|
"publishConfig": {
|
|
86
86
|
"access": "public"
|
|
87
87
|
},
|
|
88
|
-
"gitHead": "
|
|
88
|
+
"gitHead": "aef6b8ec1097cd7cd19ca9de82e17d8dfa87be71"
|
|
89
89
|
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import passport from "passport"
|
|
2
|
+
import * as Sentry from "@sentry/node"
|
|
3
|
+
import User from "../../models/user"
|
|
4
|
+
import { PROVIDERS } from "./passport"
|
|
5
|
+
import { userMigration } from "./user-migration"
|
|
6
|
+
|
|
7
|
+
const loginErrorHandler = (next) => (loginErr) => {
|
|
8
|
+
if (loginErr) {
|
|
9
|
+
Sentry.captureException(loginErr)
|
|
10
|
+
return next(loginErr) // Pass error to Express error handler
|
|
11
|
+
}
|
|
12
|
+
return next()
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
export const requestAuth = (req, res, next) =>
|
|
4
|
-
passport.authenticate(
|
|
16
|
+
passport.authenticate(PROVIDERS.GOOGLE, {
|
|
5
17
|
scope: [
|
|
6
18
|
"https://www.googleapis.com/auth/userinfo.email",
|
|
7
19
|
"https://www.googleapis.com/auth/userinfo.profile",
|
|
@@ -12,7 +24,45 @@ export const requestAuth = (req, res, next) =>
|
|
|
12
24
|
state: req.query.redirectPath || null,
|
|
13
25
|
})(req, res, next)
|
|
14
26
|
|
|
15
|
-
export const authCallback =
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
export const authCallback = async (req, res, next) => {
|
|
28
|
+
// Google auth and redirect to linking page
|
|
29
|
+
return passport.authenticate(
|
|
30
|
+
PROVIDERS.GOOGLE,
|
|
31
|
+
{ session: false },
|
|
32
|
+
async (err, user, _info) => {
|
|
33
|
+
// First we check if an ORCID user exists for this login
|
|
34
|
+
let orcidUser
|
|
35
|
+
if (user?.orcid) {
|
|
36
|
+
orcidUser = await User.findOne({
|
|
37
|
+
providerId: user.orcid,
|
|
38
|
+
provider: PROVIDERS.ORCID,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
if (orcidUser && user?.migrated) {
|
|
42
|
+
// User has ORCID linked already, redirect to login as linked account
|
|
43
|
+
return res.redirect("/crn/auth/orcid")
|
|
44
|
+
} else if (orcidUser && !(user?.migrated)) {
|
|
45
|
+
// Linked but not migrated, migrate here and then login
|
|
46
|
+
try {
|
|
47
|
+
await userMigration(orcidUser.providerId, user.id)
|
|
48
|
+
} catch (_err) {
|
|
49
|
+
// Already logged, just redirect to error page
|
|
50
|
+
return next(_err)
|
|
51
|
+
}
|
|
52
|
+
return res.redirect("/crn/auth/orcid")
|
|
53
|
+
} else {
|
|
54
|
+
if (err) {
|
|
55
|
+
Sentry.captureException(err)
|
|
56
|
+
// Redirect to a generic error page or handle specific errors if possible
|
|
57
|
+
return res.redirect("/error/google/unknown")
|
|
58
|
+
}
|
|
59
|
+
if (!user) {
|
|
60
|
+
// Authentication failed (e.g., user denied access)
|
|
61
|
+
return res.redirect("/")
|
|
62
|
+
}
|
|
63
|
+
req.query.state = Buffer.from("/orcid-link").toString("base64")
|
|
64
|
+
return req.logIn(user, { session: false }, loginErrorHandler(next))
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
)(req, res, next)
|
|
68
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import passport from "passport"
|
|
2
|
-
import User from "../../models/user"
|
|
3
2
|
import { parsedJwtFromRequest } from "./jwt"
|
|
4
3
|
import * as Sentry from "@sentry/node"
|
|
4
|
+
import { userMigration } from "./user-migration"
|
|
5
5
|
|
|
6
6
|
export const requestAuth = passport.authenticate("orcid", {
|
|
7
7
|
session: false,
|
|
@@ -20,19 +20,18 @@ export const authCallback = (req, res, next) =>
|
|
|
20
20
|
if (!user) {
|
|
21
21
|
return res.redirect("/")
|
|
22
22
|
}
|
|
23
|
+
// Google user
|
|
23
24
|
const existingAuth = parsedJwtFromRequest(req)
|
|
24
25
|
if (existingAuth) {
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
.then((
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
// Migrate Google to ORCID
|
|
27
|
+
if (existingAuth.provider === "google") {
|
|
28
|
+
return userMigration(user.providerId, existingAuth.sub).then(() => {
|
|
29
|
+
// Complete login with ORCID as primary account
|
|
30
|
+
req.logIn(user, { session: false }, (err) => {
|
|
31
|
+
return next(err)
|
|
31
32
|
})
|
|
32
33
|
})
|
|
33
|
-
|
|
34
|
-
return next(err)
|
|
35
|
-
})
|
|
34
|
+
}
|
|
36
35
|
} else {
|
|
37
36
|
// Complete login with ORCID as primary account
|
|
38
37
|
req.logIn(user, { session: false }, (err) => {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import mongoose from "mongoose"
|
|
2
|
+
import User from "../../models/user"
|
|
3
|
+
import UserMigration from "../../models/userMigration"
|
|
4
|
+
import Dataset from "../../models/dataset"
|
|
5
|
+
import Permission from "../../models/permission"
|
|
6
|
+
import Comment from "../../models/comment"
|
|
7
|
+
import Deletion from "../../models/deletion"
|
|
8
|
+
import * as Sentry from "@sentry/node"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Move content from a Google account to an ORCID one
|
|
12
|
+
*
|
|
13
|
+
* Automatic rollback and error reporting if anything here fails
|
|
14
|
+
*
|
|
15
|
+
* Records the migration steps taken in the UserMigration model
|
|
16
|
+
*
|
|
17
|
+
* @param orcid ORCID iD of the user's primary account
|
|
18
|
+
* @param userId Account being merged with the ORCID account
|
|
19
|
+
*/
|
|
20
|
+
export async function userMigration(orcid: string, userId: string) {
|
|
21
|
+
const session = await mongoose.startSession()
|
|
22
|
+
try {
|
|
23
|
+
await session.withTransaction(async () => {
|
|
24
|
+
try {
|
|
25
|
+
// Load both original records
|
|
26
|
+
const orcidUser = await User.findOne(
|
|
27
|
+
{
|
|
28
|
+
providerId: orcid,
|
|
29
|
+
provider: "orcid",
|
|
30
|
+
},
|
|
31
|
+
null,
|
|
32
|
+
{ session },
|
|
33
|
+
)
|
|
34
|
+
const googleUser = await User.findOne(
|
|
35
|
+
{
|
|
36
|
+
id: userId,
|
|
37
|
+
provider: "google",
|
|
38
|
+
},
|
|
39
|
+
null,
|
|
40
|
+
{ session },
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
// Save the original user records
|
|
44
|
+
const migration = new UserMigration({ session })
|
|
45
|
+
migration.users.push(orcidUser.toObject())
|
|
46
|
+
migration.users.push(googleUser.toObject())
|
|
47
|
+
await migration.save({ session })
|
|
48
|
+
|
|
49
|
+
// Migrate dataset ownership
|
|
50
|
+
const datasets = await Dataset.find({ uploader: googleUser.id }, null, {
|
|
51
|
+
session,
|
|
52
|
+
})
|
|
53
|
+
for (const dataset of datasets) {
|
|
54
|
+
dataset.uploader = orcidUser.id
|
|
55
|
+
// Record this dataset uploader as migrated
|
|
56
|
+
migration.datasets.push(dataset.id)
|
|
57
|
+
await dataset.save({ session })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Migrate dataset permissions
|
|
61
|
+
const permissions = await Permission.find(
|
|
62
|
+
{ userId: googleUser.id },
|
|
63
|
+
null,
|
|
64
|
+
{
|
|
65
|
+
session,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
for (const permission of permissions) {
|
|
69
|
+
permission.userId = orcidUser.id
|
|
70
|
+
// Record this permission as migrated
|
|
71
|
+
migration.permissions.push(permission.toObject())
|
|
72
|
+
await permission.save({ session })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Migrate dataset deletions
|
|
76
|
+
const deletions = await Deletion.find({ userId: googleUser.id }, null, {
|
|
77
|
+
session,
|
|
78
|
+
})
|
|
79
|
+
for (const deletion of deletions) {
|
|
80
|
+
deletion.user._id = orcidUser.id
|
|
81
|
+
// Record this deletion as migrated
|
|
82
|
+
migration.deletions.push(deletion.toObject())
|
|
83
|
+
await deletion.save({ session })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Migrate comments
|
|
87
|
+
const comments = await Comment.find({ userId: googleUser.id }, null, {
|
|
88
|
+
session,
|
|
89
|
+
})
|
|
90
|
+
for (const comment of comments) {
|
|
91
|
+
comment.user._id = orcidUser.id
|
|
92
|
+
// Record this comment as migrated
|
|
93
|
+
migration.comments.push(comment.toObject())
|
|
94
|
+
await comment.save({ session })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Migrate admin permissions if different
|
|
98
|
+
if (googleUser.admin) {
|
|
99
|
+
orcidUser.admin = true
|
|
100
|
+
}
|
|
101
|
+
// If either account is blocked should we even migrate?
|
|
102
|
+
if (googleUser.blocked) {
|
|
103
|
+
orcidUser.blocked = true
|
|
104
|
+
}
|
|
105
|
+
await orcidUser.save({ session })
|
|
106
|
+
// Save the orcid value that was actually used for future logins
|
|
107
|
+
googleUser.orcid = orcidUser.providerId
|
|
108
|
+
googleUser.migrated = true
|
|
109
|
+
await googleUser.save({ session })
|
|
110
|
+
// Save success
|
|
111
|
+
migration.success = true
|
|
112
|
+
await migration.save({ session })
|
|
113
|
+
} catch (err) {
|
|
114
|
+
Sentry.captureException(err)
|
|
115
|
+
throw err
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
} finally {
|
|
119
|
+
await session.endSession()
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/models/user.ts
CHANGED
|
@@ -5,20 +5,37 @@ const { Schema, model } = mongoose
|
|
|
5
5
|
|
|
6
6
|
export interface UserDocument extends Document {
|
|
7
7
|
_id: string
|
|
8
|
+
// OpenNeuro specific user uuid
|
|
8
9
|
id: string
|
|
10
|
+
// Best contact email for the user (notifications)
|
|
9
11
|
email: string
|
|
12
|
+
// User's preferred name (visible)
|
|
10
13
|
name: string
|
|
14
|
+
// Login provider
|
|
11
15
|
provider: StaticRangeInit
|
|
16
|
+
// The id from the login provider
|
|
12
17
|
providerId: string
|
|
18
|
+
// ORCID iD associated with this OpenNeuro user
|
|
13
19
|
orcid: string
|
|
20
|
+
// Google account id associated with this OpenNeuro user
|
|
21
|
+
google: string
|
|
22
|
+
// Is this a migrated account? Migrated accounts were Google accounts moved to ORCID and disabled
|
|
23
|
+
migrated: boolean
|
|
14
24
|
refresh: string
|
|
25
|
+
// Is this user a site admin with permissions for all datasets?
|
|
15
26
|
admin: boolean
|
|
27
|
+
// Has this user been banned from the site?
|
|
16
28
|
blocked: boolean
|
|
29
|
+
// Original account creation time
|
|
17
30
|
created: Date
|
|
31
|
+
// Last time the user authenticated
|
|
18
32
|
lastSeen: Date
|
|
19
33
|
location: string
|
|
34
|
+
// Institution populated from ORCID
|
|
20
35
|
institution: string
|
|
36
|
+
// GitHub account linked
|
|
21
37
|
github: string
|
|
38
|
+
// User profile links
|
|
22
39
|
links: string[]
|
|
23
40
|
}
|
|
24
41
|
|
|
@@ -29,6 +46,8 @@ const userSchema = new Schema({
|
|
|
29
46
|
provider: String, // Login provider
|
|
30
47
|
providerId: String, // Login provider unique id
|
|
31
48
|
orcid: String, // ORCID iD regardless of provider id
|
|
49
|
+
google: String, // Google ID if this is an ORCID account with a Google account linked
|
|
50
|
+
migrated: { type: Boolean, default: false },
|
|
32
51
|
refresh: String,
|
|
33
52
|
admin: { type: Boolean, default: false },
|
|
34
53
|
blocked: { type: Boolean, default: false },
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid"
|
|
2
|
+
import mongoose from "mongoose"
|
|
3
|
+
import type { Document } from "mongoose"
|
|
4
|
+
const { Schema, model } = mongoose
|
|
5
|
+
|
|
6
|
+
export interface UserMigrationDocument extends Document {
|
|
7
|
+
_id: string
|
|
8
|
+
orcid: string
|
|
9
|
+
google: string
|
|
10
|
+
users: object[]
|
|
11
|
+
datasets: string[]
|
|
12
|
+
permissions: object[]
|
|
13
|
+
comments: object[]
|
|
14
|
+
deletions: object[]
|
|
15
|
+
success: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const userMigrationSchema = new Schema({
|
|
19
|
+
id: { type: String, default: uuidv4 }, // OpenNeuro id
|
|
20
|
+
orcid: String,
|
|
21
|
+
google: String,
|
|
22
|
+
datasets: { type: [String], default: [] },
|
|
23
|
+
permissions: { type: [Object], default: [] },
|
|
24
|
+
comments: { type: [String], default: [] },
|
|
25
|
+
deletions: { type: [String], default: [] },
|
|
26
|
+
users: { type: [Object], default: [] },
|
|
27
|
+
success: Boolean,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const User = model<UserMigrationDocument>("UserMigration", userMigrationSchema)
|
|
31
|
+
|
|
32
|
+
export default User
|
package/src/routes.ts
CHANGED
|
@@ -178,6 +178,7 @@ const router = express.Router()
|
|
|
178
178
|
|
|
179
179
|
for (const route of routes) {
|
|
180
180
|
const arr = Object.hasOwn(route, "middleware") ? route.middleware : []
|
|
181
|
+
// @ts-expect-error This is actually working.
|
|
181
182
|
arr.unshift(route.url)
|
|
182
183
|
arr.push(route.handler)
|
|
183
184
|
router[route.method](...arr)
|