@semapps/auth 0.3.21 → 0.4.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/index.js CHANGED
@@ -1,9 +1,8 @@
1
1
  module.exports = {
2
- AuthService: require('./services/auth'),
2
+ AuthCASService: require('./services/auth.cas'),
3
+ AuthLocalService: require('./services/auth.local'),
4
+ AuthOIDCService: require('./services/auth.oidc'),
3
5
  AuthAccountService: require('./services/account'),
4
6
  AuthJWTService: require('./services/jwt'),
5
- Connector: require('./Connector'),
6
- CasConnector: require('./CasConnector'),
7
- LocalConnector: require('./LocalConnector'),
8
- OidcConnector: require('./OidcConnector')
7
+ AuthMigrationService: require('./services/migration')
9
8
  };
@@ -0,0 +1,6 @@
1
+ const localLogout = (req, res, next) => {
2
+ req.logout(); // Passport logout
3
+ next();
4
+ };
5
+
6
+ module.exports = localLogout;
@@ -0,0 +1,14 @@
1
+ const redirectToFront = (req, res) => {
2
+ // Redirect browser to the redirect URL pushed in session
3
+ let redirectUrl = new URL(req.session.redirectUrl);
4
+ if (req.user) {
5
+ // If a token was stored, add it to the URL so that the client may use it
6
+ if (req.user.token) redirectUrl.searchParams.set('token', req.user.token);
7
+ redirectUrl.searchParams.set('new', req.user.newUser ? 'true' : 'false');
8
+ }
9
+ // Redirect using NodeJS HTTP
10
+ res.writeHead(302, { Location: redirectUrl.toString() });
11
+ res.end();
12
+ };
13
+
14
+ module.exports = redirectToFront;
@@ -0,0 +1,9 @@
1
+ const saveRedirectUrl = (req, res, next) => {
2
+ // Persist referer on the session to get it back after redirection
3
+ // If the redirectUrl is already in the session, use it as default value
4
+ req.session.redirectUrl =
5
+ req.query.redirectUrl || (req.session && req.session.redirectUrl) || req.headers.referer || '/';
6
+ next();
7
+ };
8
+
9
+ module.exports = saveRedirectUrl;
@@ -0,0 +1,6 @@
1
+ const sendToken = (req, res) => {
2
+ res.setHeader('Content-Type', 'application/json');
3
+ res.end(JSON.stringify({ token: req.user.token, newUser: req.user.newUser }));
4
+ };
5
+
6
+ module.exports = sendToken;
package/mixins/auth.js ADDED
@@ -0,0 +1,115 @@
1
+ const AuthAccountService = require('../services/account');
2
+ const AuthJWTService = require('../services/jwt');
3
+ const { Errors: E } = require('moleculer-web');
4
+ const passport = require('passport');
5
+
6
+ const AuthMixin = {
7
+ settings: {
8
+ baseUrl: null,
9
+ jwtPath: null,
10
+ registrationAllowed: true,
11
+ reservedUsernames: [],
12
+ webIdSelection: []
13
+ },
14
+ dependencies: ['api', 'webid'],
15
+ async created() {
16
+ const { jwtPath, reservedUsernames } = this.settings;
17
+
18
+ await this.broker.createService(AuthJWTService, {
19
+ settings: { jwtPath }
20
+ });
21
+
22
+ await this.broker.createService(AuthAccountService, {
23
+ settings: { reservedUsernames }
24
+ });
25
+ },
26
+ async started() {
27
+ if (!this.passportId) throw new Error('this.passportId must be set in the service creation.');
28
+
29
+ this.passport = passport;
30
+ this.passport.serializeUser((user, done) => {
31
+ done(null, user);
32
+ });
33
+ this.passport.deserializeUser((user, done) => {
34
+ done(null, user);
35
+ });
36
+
37
+ this.strategy = this.getStrategy();
38
+
39
+ this.passport.use(this.passportId, this.strategy);
40
+
41
+ for (let route of this.getApiRoutes()) {
42
+ await this.broker.call('api.addRoute', { route });
43
+ }
44
+ },
45
+ actions: {
46
+ // See https://moleculer.services/docs/0.13/moleculer-web.html#Authentication
47
+ async authenticate(ctx) {
48
+ const { route, req, res } = ctx.params;
49
+ // Extract token from authorization header (do not take the Bearer part)
50
+ const token = req.headers.authorization && req.headers.authorization.split(' ')[1];
51
+ if (token) {
52
+ const payload = await ctx.call('auth.jwt.verifyToken', { token });
53
+ if (payload) {
54
+ ctx.meta.tokenPayload = payload;
55
+ ctx.meta.webId = payload.webId;
56
+ return Promise.resolve(payload);
57
+ } else {
58
+ // Invalid token
59
+ // TODO make sure token is deleted client-side
60
+ ctx.meta.webId = 'anon';
61
+ return Promise.reject(new E.UnAuthorizedError(E.ERR_INVALID_TOKEN));
62
+ }
63
+ } else {
64
+ // No token, anonymous error
65
+ ctx.meta.webId = 'anon';
66
+ return Promise.resolve(null);
67
+ }
68
+ },
69
+ // See https://moleculer.services/docs/0.13/moleculer-web.html#Authorization
70
+ async authorize(ctx) {
71
+ const { route, req, res } = ctx.params;
72
+ // Extract token from authorization header (do not take the Bearer part)
73
+ const token = req.headers.authorization && req.headers.authorization.split(' ')[1];
74
+ if (token) {
75
+ const payload = await ctx.call('auth.jwt.verifyToken', { token });
76
+ if (payload) {
77
+ ctx.meta.tokenPayload = payload;
78
+ ctx.meta.webId = payload.webId;
79
+ return Promise.resolve(payload);
80
+ } else {
81
+ ctx.meta.webId = 'anon';
82
+ return Promise.reject(new E.UnAuthorizedError(E.ERR_INVALID_TOKEN));
83
+ }
84
+ } else {
85
+ ctx.meta.webId = 'anon';
86
+ return Promise.reject(new E.UnAuthorizedError(E.ERR_NO_TOKEN));
87
+ }
88
+ },
89
+ async impersonate(ctx) {
90
+ const { webId } = ctx.params;
91
+ return await ctx.call('auth.jwt.generateToken', {
92
+ payload: {
93
+ webId
94
+ }
95
+ });
96
+ }
97
+ },
98
+ methods: {
99
+ getStrategy() {
100
+ throw new Error('getStrategy must be implemented by the service');
101
+ },
102
+ getApiRoutes() {
103
+ throw new Error('getApiRoutes must be implemented by the service');
104
+ },
105
+ pickWebIdData(data) {
106
+ if (this.settings.webIdSelection.length > 0) {
107
+ return Object.fromEntries(this.settings.webIdSelection.filter(key => key in data).map(key => [key, data[key]]));
108
+ } else {
109
+ return data;
110
+ }
111
+ }
112
+ }
113
+ };
114
+
115
+ module.exports = AuthMixin;
@@ -0,0 +1,81 @@
1
+ const session = require('express-session');
2
+ const AuthMixin = require('./auth');
3
+ const saveRedirectUrl = require('../middlewares/saveRedirectUrl');
4
+ const redirectToFront = require('../middlewares/redirectToFront');
5
+ const localLogout = require('../middlewares/localLogout');
6
+
7
+ const AuthSSOMixin = {
8
+ mixins: [AuthMixin],
9
+ settings: {
10
+ baseUrl: null,
11
+ jwtPath: null,
12
+ registrationAllowed: true,
13
+ reservedUsernames: [],
14
+ webIdSelection: [],
15
+ // SSO-specific settings
16
+ sessionSecret: 's€m@pps',
17
+ selectSsoData: null
18
+ },
19
+ actions: {
20
+ async loginOrSignup(ctx) {
21
+ let { ssoData } = ctx.params;
22
+
23
+ const profileData = this.settings.selectSsoData ? await this.settings.selectSsoData(ssoData) : ssoData;
24
+
25
+ // TODO use UUID to identify unique accounts with SSO
26
+ const existingAccounts = await ctx.call('auth.account.find', { query: { email: profileData.email } });
27
+
28
+ let accountData, webId, newUser;
29
+ if (existingAccounts.length > 0) {
30
+ accountData = existingAccounts[0];
31
+ webId = accountData.webId;
32
+ newUser = false;
33
+
34
+ // TODO update account with recent information
35
+ // await this.broker.call('webid.edit', profileData, { meta: { webId } });
36
+
37
+ await this.broker.emit('auth.connected', { webId, accountData });
38
+ } else {
39
+ if (!this.settings.registrationAllowed) {
40
+ throw new Error('registration.not-allowed');
41
+ }
42
+
43
+ accountData = await ctx.call('auth.account.create', { uuid: profileData.uuid, email: profileData.email });
44
+ webId = await ctx.call('webid.create', { nick: accountData.username, ...profileData });
45
+ newUser = true;
46
+
47
+ // Link the webId with the account
48
+ await ctx.call('auth.account.attachWebId', { accountUri: accountData['@id'], webId });
49
+
50
+ ctx.emit('auth.registered', { webId, profileData, accountData }, { meta: { webId: null, dataset: null } });
51
+ }
52
+
53
+ const token = await ctx.call('auth.jwt.generateToken', { payload: { webId: accountData.webId } });
54
+
55
+ return { token, newUser };
56
+ }
57
+ },
58
+ methods: {
59
+ getApiRoutes() {
60
+ const sessionMiddleware = session({ secret: this.settings.sessionSecret, maxAge: null });
61
+ return [
62
+ {
63
+ path: '/auth',
64
+ use: [sessionMiddleware, this.passport.initialize(), this.passport.session()],
65
+ aliases: {
66
+ 'GET /': [saveRedirectUrl, this.passport.authenticate(this.passportId, { session: false }), redirectToFront]
67
+ }
68
+ },
69
+ {
70
+ path: '/auth/logout',
71
+ use: [sessionMiddleware, this.passport.initialize(), this.passport.session()],
72
+ aliases: {
73
+ 'GET /': [saveRedirectUrl, localLogout, redirectToFront]
74
+ }
75
+ }
76
+ ];
77
+ }
78
+ }
79
+ };
80
+
81
+ module.exports = AuthSSOMixin;
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "@semapps/auth",
3
- "version": "0.3.21",
3
+ "version": "0.4.0-alpha.0",
4
4
  "description": "Authentification module for SemApps",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Virtual Assembly",
7
7
  "dependencies": {
8
- "@semapps/mime-types": "0.3.21",
8
+ "@semapps/mime-types": "0.4.0-alpha.0",
9
+ "@semapps/triplestore": "0.4.0-alpha.0",
9
10
  "bcrypt": "^5.0.1",
10
11
  "express-session": "^1.17.0",
11
12
  "jsonwebtoken": "^8.5.1",
12
- "moleculer": "^0.14.18",
13
+ "moleculer": "^0.14.17",
14
+ "moleculer-db": "^0.8.16",
13
15
  "moleculer-web": "^0.10.0-beta1",
14
16
  "openid-client": "^4.7.4",
15
17
  "passport": "^0.4.1",
@@ -20,5 +22,5 @@
20
22
  "publishConfig": {
21
23
  "access": "public"
22
24
  },
23
- "gitHead": "26bdfa9df08eaa1da5b8c2c0d4c98eed4f08ef96"
25
+ "gitHead": "1c0962ddc0bc571bcbdd4af58efc72a2569889ef"
24
26
  }
@@ -1,59 +1,69 @@
1
1
  const bcrypt = require('bcrypt');
2
- const { MIME_TYPES } = require('@semapps/mime-types');
2
+ const DbService = require('moleculer-db');
3
+ const { TripleStoreAdapter } = require('@semapps/triplestore');
3
4
 
4
5
  module.exports = {
5
6
  name: 'auth.account',
7
+ mixins: [DbService],
8
+ adapter: new TripleStoreAdapter({ type: 'AuthAccount', dataset: 'settings' }),
6
9
  settings: {
7
- containerUri: null
10
+ idField: '@id',
11
+ reservedUsernames: []
8
12
  },
9
- dependencies: ['ldp', 'triplestore'],
13
+ dependencies: ['triplestore'],
10
14
  actions: {
11
15
  async create(ctx) {
12
- const { email, password, webId } = ctx.params;
13
- const hashedPassword = await this.hashPassword(password);
14
- const accountUri = await ctx.call('ldp.resource.post', {
15
- containerUri: this.settings.containerUri,
16
- resource: {
17
- '@context': {
18
- semapps: 'http://semapps.org/ns/core#'
19
- },
20
- '@type': 'semapps:Account',
21
- 'semapps:email': email,
22
- 'semapps:password': hashedPassword,
23
- 'semapps:webId': webId
24
- },
25
- contentType: MIME_TYPES.JSON,
16
+ let { uuid, username, password, email, webId } = ctx.params;
17
+ const hashedPassword = password ? await this.hashPassword(password) : undefined;
18
+
19
+ const emailExists = await ctx.call('auth.account.emailExists', { email });
20
+ if (emailExists) {
21
+ throw new Error('email.already.exists');
22
+ }
23
+
24
+ if (username) {
25
+ await this.isValidUsername(ctx, username);
26
+ } else {
27
+ // If username is not provided, find an username based on the email
28
+ let usernameValid = false,
29
+ i = 1;
30
+ do {
31
+ i++;
32
+ username = email.split('@')[0].toLowerCase();
33
+ if (i > 2) username += i;
34
+ usernameValid = await this.isValidUsername(ctx, username);
35
+ } while (usernameValid);
36
+ }
37
+
38
+ return await this._create(ctx, {
39
+ uuid,
40
+ username,
41
+ email,
42
+ hashedPassword,
26
43
  webId
27
44
  });
45
+ },
46
+ async attachWebId(ctx) {
47
+ const { accountUri, webId } = ctx.params;
28
48
 
29
- return await ctx.call('ldp.resource.get', {
30
- resourceUri: accountUri,
31
- accept: MIME_TYPES.JSON,
49
+ return await this._update(ctx, {
50
+ '@id': accountUri,
32
51
  webId
33
52
  });
34
53
  },
35
54
  async verify(ctx) {
36
- const { email, password } = ctx.params;
37
- const results = await ctx.call('triplestore.query', {
38
- query: `
39
- PREFIX semapps: <http://semapps.org/ns/core#>
40
- PREFIX ldp: <http://www.w3.org/ns/ldp#>
41
- SELECT ?accountUri ?passwordHash ?webId
42
- WHERE {
43
- <${this.settings.containerUri}> ldp:contains ?accountUri .
44
- ?accountUri semapps:email '${email}' .
45
- ?accountUri semapps:password ?passwordHash .
46
- ?accountUri semapps:webId ?webId .
47
- }
48
- `,
49
- accept: MIME_TYPES.JSON,
50
- webId: 'system'
55
+ const { username, password } = ctx.params;
56
+
57
+ const accounts = await this._find(ctx, {
58
+ query: {
59
+ username
60
+ }
51
61
  });
52
62
 
53
- if (results.length > 0) {
54
- const passwordMatch = await this.comparePassword(password, results[0].passwordHash.value);
63
+ if (accounts.length > 0) {
64
+ const passwordMatch = await this.comparePassword(password, accounts[0].hashedPassword);
55
65
  if (passwordMatch) {
56
- return { accountUri: results[0].accountUri.value, webId: results[0].webId.value };
66
+ return accounts[0];
57
67
  } else {
58
68
  throw new Error('account.not-found');
59
69
  }
@@ -61,43 +71,50 @@ module.exports = {
61
71
  throw new Error('account.not-found');
62
72
  }
63
73
  },
64
- async findByEmail(ctx) {
74
+ async usernameExists(ctx) {
75
+ const { username } = ctx.params;
76
+ const accounts = await this._find(ctx, { query: { username } });
77
+ return accounts.length > 0;
78
+ },
79
+ async emailExists(ctx) {
65
80
  const { email } = ctx.params;
66
- const results = await ctx.call('triplestore.query', {
67
- query: `
68
- PREFIX semapps: <http://semapps.org/ns/core#>
69
- PREFIX ldp: <http://www.w3.org/ns/ldp#>
70
- SELECT ?accountUri ?webId
71
- WHERE {
72
- <${this.settings.containerUri}> ldp:contains ?accountUri .
73
- ?accountUri semapps:email '${email}' .
74
- ?accountUri semapps:webId ?webId .
75
- }
76
- `,
77
- accept: MIME_TYPES.JSON,
78
- webId: 'system'
79
- });
80
- return results.length > 0 ? results[0].webId.value : null;
81
+ const accounts = await this._find(ctx, { query: { email } });
82
+ return accounts.length > 0;
81
83
  },
82
84
  async findByWebId(ctx) {
83
85
  const { webId } = ctx.params;
84
- const results = await ctx.call('triplestore.query', {
85
- query: `
86
- PREFIX semapps: <http://semapps.org/ns/core#>
87
- PREFIX ldp: <http://www.w3.org/ns/ldp#>
88
- SELECT ?accountUri
89
- WHERE {
90
- <${this.settings.containerUri}> ldp:contains ?accountUri .
91
- ?accountUri semapps:webId "${webId}" .
92
- }
93
- `,
94
- accept: MIME_TYPES.JSON,
95
- webId: 'system'
86
+ const accounts = await this._find(ctx, { query: { webId } });
87
+ return accounts.length > 0 ? accounts[0] : null;
88
+ },
89
+ async setPassword(ctx) {
90
+ const { webId, password } = ctx.params;
91
+ const hashedPassword = await this.hashPassword(password);
92
+ const account = await ctx.call('auth.account.findByWebId', { webId });
93
+
94
+ return await this._update(ctx, {
95
+ '@id': account.id,
96
+ hashedPassword
96
97
  });
97
- return results.length > 0 ? results[0].accountUri.value : null;
98
98
  }
99
99
  },
100
100
  methods: {
101
+ async isValidUsername(ctx, username) {
102
+ // Ensure the username has no space or special characters
103
+ if (!/^[a-zA-Z0-9\-_.]+$/.exec(username)) {
104
+ throw new Error('username.invalid');
105
+ }
106
+
107
+ // Ensure we don't use reservedUsernames
108
+ if (this.settings.reservedUsernames.includes(username)) {
109
+ throw new Error('username.already.exists');
110
+ }
111
+
112
+ // Ensure email or username doesn't already exist
113
+ const usernameExists = await ctx.call('auth.account.usernameExists', { username });
114
+ if (usernameExists) {
115
+ throw new Error('username.already.exists');
116
+ }
117
+ },
101
118
  async hashPassword(password) {
102
119
  return new Promise((resolve, reject) => {
103
120
  bcrypt.hash(password, 10, (err, hash) => {
@@ -0,0 +1,45 @@
1
+ const { Strategy } = require('passport-cas2');
2
+ const AuthSSOMixin = require('../mixins/auth.sso');
3
+ const { Errors: E } = require('moleculer-web');
4
+
5
+ const AuthCASService = {
6
+ name: 'auth',
7
+ mixins: [AuthSSOMixin],
8
+ settings: {
9
+ baseUrl: null,
10
+ jwtPath: null,
11
+ registrationAllowed: true,
12
+ reservedUsernames: [],
13
+ webIdSelection: [],
14
+ // SSO-specific settings
15
+ sessionSecret: 's€m@pps',
16
+ selectSsoData: null,
17
+ // Cas-specific settings
18
+ casUrl: null
19
+ },
20
+ async created() {
21
+ this.passportId = 'cas';
22
+ },
23
+ methods: {
24
+ getStrategy() {
25
+ return new Strategy(
26
+ {
27
+ casURL: this.settings.casUrl,
28
+ passReqToCallback: true
29
+ },
30
+ (req, username, profile, done) => {
31
+ req.$ctx
32
+ .call('auth.loginOrSignup', { ssoData: { username, ...profile } })
33
+ .then(loginData => {
34
+ done(null, loginData);
35
+ })
36
+ .catch(e => {
37
+ done(new E.UnAuthorizedError(e.message), false);
38
+ });
39
+ }
40
+ );
41
+ }
42
+ }
43
+ };
44
+
45
+ module.exports = AuthCASService;
@@ -0,0 +1,95 @@
1
+ const { Strategy } = require('passport-local');
2
+ const AuthMixin = require('../mixins/auth');
3
+ const sendToken = require('../middlewares/sendToken');
4
+ const { MoleculerError } = require('moleculer').Errors;
5
+
6
+ const AuthLocalService = {
7
+ name: 'auth',
8
+ mixins: [AuthMixin],
9
+ settings: {
10
+ baseUrl: null,
11
+ jwtPath: null,
12
+ registrationAllowed: true,
13
+ reservedUsernames: [],
14
+ webIdSelection: []
15
+ },
16
+ created() {
17
+ this.passportId = 'local';
18
+ },
19
+ actions: {
20
+ async signup(ctx) {
21
+ const { username, email, password, ...otherData } = ctx.params;
22
+
23
+ let accountData = await ctx.call('auth.account.create', { username, email, password });
24
+
25
+ const profileData = { nick: username, email, ...otherData };
26
+ const webId = await ctx.call('webid.create', this.pickWebIdData(profileData));
27
+
28
+ // Link the webId with the account
29
+ accountData = await ctx.call('auth.account.attachWebId', { accountUri: accountData['@id'], webId });
30
+
31
+ ctx.emit('auth.registered', { webId, profileData, accountData }, { meta: { webId: null, dataset: null } });
32
+
33
+ const token = await ctx.call('auth.jwt.generateToken', { payload: { webId } });
34
+
35
+ return { token, webId, newUser: true };
36
+ },
37
+ async login(ctx) {
38
+ const { username, password } = ctx.params;
39
+
40
+ const accountData = await ctx.call('auth.account.verify', { username, password });
41
+
42
+ ctx.emit('auth.connected', { webId: accountData.webId, accountData }, { meta: { webId: null, dataset: null } });
43
+
44
+ const token = await ctx.call('auth.jwt.generateToken', { payload: { webId: accountData.webId } });
45
+
46
+ return { token, webId: accountData.webId, newUser: true };
47
+ }
48
+ },
49
+ methods: {
50
+ getStrategy() {
51
+ return new Strategy(
52
+ {
53
+ usernameField: 'username',
54
+ passwordField: 'password',
55
+ passReqToCallback: true // We want to have access to req below
56
+ },
57
+ (req, username, password, done) => {
58
+ req.$ctx
59
+ .call('auth.login', { username, password })
60
+ .then(returnedData => {
61
+ done(null, returnedData);
62
+ })
63
+ .catch(e => {
64
+ console.error(e);
65
+ done(new MoleculerError(e.message, 401), false);
66
+ });
67
+ }
68
+ );
69
+ },
70
+ getApiRoutes() {
71
+ const loginRoute = {
72
+ path: '/auth/login',
73
+ use: [this.passport.initialize()],
74
+ aliases: {
75
+ 'POST /': [this.passport.authenticate(this.passportId, { session: false }), sendToken]
76
+ }
77
+ };
78
+
79
+ const signupRoute = {
80
+ path: '/auth/signup',
81
+ aliases: {
82
+ 'POST /': 'auth.signup'
83
+ }
84
+ };
85
+
86
+ if (this.settings.registrationAllowed) {
87
+ return [loginRoute, signupRoute];
88
+ } else {
89
+ return [loginRoute];
90
+ }
91
+ }
92
+ }
93
+ };
94
+
95
+ module.exports = AuthLocalService;
@@ -0,0 +1,65 @@
1
+ const urlJoin = require('url-join');
2
+ const { Issuer, Strategy } = require('openid-client');
3
+ const AuthSSOMixin = require('../mixins/auth.sso');
4
+
5
+ const AuthOIDCService = {
6
+ name: 'auth',
7
+ mixins: [AuthSSOMixin],
8
+ settings: {
9
+ baseUrl: null,
10
+ jwtPath: null,
11
+ registrationAllowed: true,
12
+ reservedUsernames: [],
13
+ webIdSelection: [],
14
+ // SSO-specific settings
15
+ sessionSecret: 's€m@pps',
16
+ selectSsoData: null,
17
+ // OIDC-specific settings
18
+ issuer: null,
19
+ clientId: null,
20
+ clientSecret: null
21
+ },
22
+ async created() {
23
+ this.passportId = 'oidc';
24
+ this.issuer = await Issuer.discover(this.settings.issuer);
25
+ },
26
+ methods: {
27
+ getStrategy() {
28
+ const client = new this.issuer.Client({
29
+ client_id: this.settings.clientId,
30
+ client_secret: this.settings.clientSecret,
31
+ redirect_uri: urlJoin(this.settings.baseUrl, 'auth'),
32
+ token_endpoint_auth_method: this.settings.clientSecret ? undefined : 'none'
33
+ });
34
+
35
+ const params = {
36
+ // ... any authorization params override client properties
37
+ // client_id defaults to client.client_id
38
+ // redirect_uri defaults to client.redirect_uris[0]
39
+ // response type defaults to client.response_types[0], then 'code'
40
+ // scope defaults to 'openid'
41
+ };
42
+
43
+ return new Strategy(
44
+ {
45
+ client,
46
+ params,
47
+ passReqToCallback: true
48
+ },
49
+ (req, tokenset, userinfo, done) => {
50
+ req.$ctx
51
+ .call('auth.loginOrSignup', { ssoData: userinfo })
52
+ .then(loginData => {
53
+ done(null, loginData);
54
+ })
55
+ .catch(e => {
56
+ console.error(e);
57
+ done(null, false);
58
+ });
59
+ }
60
+ );
61
+ }
62
+ }
63
+ };
64
+
65
+ module.exports = AuthOIDCService;
@@ -0,0 +1,24 @@
1
+ const { MIME_TYPES } = require('@semapps/mime-types');
2
+
3
+ module.exports = {
4
+ name: 'auth.migration',
5
+ actions: {
6
+ async migrateUsersToAccounts(ctx) {
7
+ const { usersContainer, emailPredicate, usernamePredicate } = ctx.params;
8
+
9
+ const results = await ctx.call('ldp.container.get', { containerUri: usersContainer, accept: MIME_TYPES.JSON });
10
+
11
+ for (let user of results['ldp:contains']) {
12
+ if (user[emailPredicate]) {
13
+ await ctx.call('auth.account.create', {
14
+ email: user[emailPredicate],
15
+ username: user[usernamePredicate],
16
+ webId: user.id
17
+ });
18
+ } else {
19
+ console.log('No email found for user ' + user.id);
20
+ }
21
+ }
22
+ }
23
+ }
24
+ };
package/CasConnector.js DELETED
@@ -1,40 +0,0 @@
1
- const { Strategy } = require('passport-cas2');
2
- const Connector = require('./Connector');
3
-
4
- class CasConnector extends Connector {
5
- constructor(settings) {
6
- const { casUrl, ...otherSettings } = settings;
7
- super('cas', {
8
- casUrl,
9
- ...otherSettings
10
- });
11
- }
12
- async initialize() {
13
- this.passport.serializeUser(function(user, done) {
14
- done(null, user);
15
- });
16
-
17
- this.passport.deserializeUser(function(user, done) {
18
- done(null, user);
19
- });
20
-
21
- this.casStrategy = new Strategy(
22
- {
23
- casURL: this.settings.casUrl
24
- },
25
- (username, profile, done) => {
26
- done(null, profile);
27
- }
28
- );
29
-
30
- this.passport.use(this.casStrategy);
31
- }
32
- globalLogout(req, res, next) {
33
- // We access directly the `cas` object in order to set the doRedirect parameter as false
34
- // and redirect to /cas/logout?url={redirectUrl} instead of/cas/logout?service={redirectUrl}
35
- // https://github.com/appdevdesigns/passport-cas/blob/master/lib/passport-cas.js#L264
36
- this.casStrategy.cas.logout(req, res, req.session.redirectUrl, false);
37
- }
38
- }
39
-
40
- module.exports = CasConnector;
package/Connector.js DELETED
@@ -1,174 +0,0 @@
1
- const passport = require('passport');
2
- const session = require('express-session');
3
- const E = require('moleculer-web').Errors;
4
-
5
- class Connector {
6
- constructor(passportId, settings) {
7
- this.passport = passport;
8
- this.passportId = passportId;
9
- this.settings = {
10
- sessionSecret: settings.sessionSecret || 's€m@pps',
11
- selectProfileData: settings.selectProfileData,
12
- findOrCreateProfile: settings.findOrCreateProfile,
13
- redirectUri: settings.redirectUri,
14
- ...settings
15
- };
16
- }
17
- saveRedirectUrl(req, res, next) {
18
- // Persist referer on the session to get it back after redirection
19
- // If the redirectUrl is already in the session, use it as default value
20
- req.session.redirectUrl =
21
- req.query.redirectUrl || (req.session && req.session.redirectUrl) || req.headers.referer || '/';
22
- next();
23
- }
24
- async findOrCreateProfile(req, res, next) {
25
- // Select profile data amongst all the data returned by the connector
26
- const profileData = this.settings.selectProfileData ? await this.settings.selectProfileData(req.user) : req.user;
27
- try {
28
- const { webId, newUser } = await this.settings.findOrCreateProfile(profileData, req.user);
29
- req.user.webId = webId;
30
- req.user.newUser = newUser;
31
- next();
32
- } catch (e) {
33
- this.redirectWithError(res, req, e);
34
- }
35
- }
36
- async generateToken(req, res, next) {
37
- // If token is already provided by the connector, skip this step.
38
- if (!req.user.token) {
39
- const profileData = this.settings.selectProfileData ? await this.settings.selectProfileData(req.user) : req.user;
40
- const payload = { webId: req.user.webId, ...profileData };
41
- req.user.token = await req.$ctx.call('auth.jwt.generateToken', { payload });
42
- }
43
- next();
44
- }
45
- localLogout(req, res, next) {
46
- req.logout(); // Passport logout
47
- next();
48
- }
49
- globalLogout(req, res, next) {
50
- // Must be implemented in extended class
51
- next();
52
- }
53
- redirectToFront(req, res, next) {
54
- // Redirect browser to the redirect URL pushed in session
55
- let redirectUrl = new URL(req.session.redirectUrl);
56
- if (req.user) {
57
- // If a token was stored, add it to the URL so that the client may use it
58
- if (req.user.token) redirectUrl.searchParams.set('token', req.user.token);
59
- redirectUrl.searchParams.set('new', req.user.newUser ? 'true' : 'false');
60
- }
61
- // Redirect using NodeJS HTTP
62
- res.writeHead(302, { Location: redirectUrl.toString() });
63
- res.end();
64
- next();
65
- }
66
- redirectWithError(res, req, error) {
67
- let redirectUrl = new URL(req.session.redirectUrl);
68
- redirectUrl.searchParams.set('error', error.message);
69
- res.writeHead(302, { Location: redirectUrl.toString() });
70
- res.end();
71
- }
72
- login() {
73
- return async (req, res) => {
74
- const middlewares = [
75
- this.saveRedirectUrl.bind(this),
76
- this.passport.authenticate(this.passportId, {
77
- session: false
78
- }),
79
- this.findOrCreateProfile.bind(this),
80
- this.generateToken.bind(this),
81
- this.redirectToFront.bind(this)
82
- ];
83
-
84
- await this.runMiddlewares(middlewares, req, res);
85
- };
86
- }
87
- signup() {
88
- // By default, signup and login are the same.
89
- return this.login();
90
- }
91
- logout() {
92
- return async (req, res) => {
93
- let middlewares = [
94
- this.saveRedirectUrl.bind(this),
95
- this.localLogout.bind(this),
96
- req.query.global === 'true' ? this.globalLogout.bind(this) : this.redirectToFront.bind(this)
97
- ];
98
-
99
- await this.runMiddlewares(middlewares, req, res);
100
- };
101
- }
102
- async runMiddlewares(middlewares, req, res) {
103
- for (const middleware of middlewares) {
104
- let asyncRes;
105
- let error = await new Promise(resolve => {
106
- try {
107
- asyncRes = middleware(req, res, resolve);
108
- } catch (e) {
109
- console.log(e);
110
- resolve(e);
111
- }
112
- });
113
- if (error) {
114
- this.redirectWithError(res, req, error);
115
- }
116
- await asyncRes;
117
- }
118
- }
119
- getRouteMiddlewares(passport) {
120
- const sessionMiddleware = session({
121
- secret: this.settings.sessionSecret,
122
- maxAge: null
123
- });
124
-
125
- if (passport) {
126
- return [sessionMiddleware, this.passport.initialize(), this.passport.session()];
127
- } else {
128
- return [sessionMiddleware];
129
- }
130
- }
131
- // See https://moleculer.services/docs/0.13/moleculer-web.html#Authentication
132
- async authenticate(ctx, route, req, res) {
133
- // Extract token from authorization header (do not take the Bearer part)
134
- const token = req.headers.authorization && req.headers.authorization.split(' ')[1];
135
- if (token) {
136
- const payload = await ctx.call('auth.jwt.verifyToken', { token });
137
- if (payload) {
138
- ctx.meta.tokenPayload = payload;
139
- ctx.meta.webId = payload.webId;
140
- return Promise.resolve(payload);
141
- } else {
142
- // Invalid token
143
- // TODO make sure token is deleted client-side
144
- ctx.meta.webId = 'anon';
145
- return Promise.reject(new E.UnAuthorizedError(E.ERR_INVALID_TOKEN));
146
- }
147
- } else {
148
- // No token, anonymous error
149
- ctx.meta.webId = 'anon';
150
- return Promise.resolve(null);
151
- }
152
- }
153
- // See https://moleculer.services/docs/0.13/moleculer-web.html#Authorization
154
- async authorize(ctx, route, req, res) {
155
- // Extract token from authorization header (do not take the Bearer part)
156
- const token = req.headers.authorization && req.headers.authorization.split(' ')[1];
157
- if (token) {
158
- const payload = await ctx.call('auth.jwt.verifyToken', { token });
159
- if (payload) {
160
- ctx.meta.tokenPayload = payload;
161
- ctx.meta.webId = payload.webId;
162
- return Promise.resolve(payload);
163
- } else {
164
- ctx.meta.webId = 'anon';
165
- return Promise.reject(new E.UnAuthorizedError(E.ERR_INVALID_TOKEN));
166
- }
167
- } else {
168
- ctx.meta.webId = 'anon';
169
- return Promise.reject(new E.UnAuthorizedError(E.ERR_NO_TOKEN));
170
- }
171
- }
172
- }
173
-
174
- module.exports = Connector;
package/LocalConnector.js DELETED
@@ -1,104 +0,0 @@
1
- const { Strategy } = require('passport-local');
2
- const Connector = require('./Connector');
3
- const { MIME_TYPES } = require('@semapps/mime-types');
4
-
5
- class LocalConnector extends Connector {
6
- constructor(settings) {
7
- super('local', settings || {});
8
- }
9
- async initialize() {
10
- this.localStrategy = new Strategy(
11
- {
12
- usernameField: 'email',
13
- passwordField: 'password',
14
- passReqToCallback: true // We want to have access to req below
15
- },
16
- (req, email, password, done) => {
17
- req.$ctx
18
- .call('auth.account.verify', { email, password })
19
- .then(({ webId }) =>
20
- req.$ctx.call('ldp.resource.get', {
21
- resourceUri: webId,
22
- accept: MIME_TYPES.JSON,
23
- webId: 'system'
24
- })
25
- )
26
- .then(userData => {
27
- req.$ctx.emit('auth.connected', { webId: userData.id, profileData: userData });
28
- done(null, { ...userData, webId: userData.id });
29
- })
30
- .catch(e => {
31
- console.error(e);
32
- done(null, false);
33
- });
34
- }
35
- );
36
-
37
- this.passport.use(this.localStrategy);
38
- }
39
- login() {
40
- return async (req, res) => {
41
- const middlewares = [
42
- this.passport.authenticate(this.passportId, {
43
- session: false
44
- }),
45
- this.generateToken.bind(this),
46
- this.sendToken.bind(this)
47
- ];
48
-
49
- await this.runMiddlewares(middlewares, req, res);
50
- };
51
- }
52
- signup() {
53
- return async (req, res) => {
54
- const middlewares = [this.createAccount.bind(this), this.generateToken.bind(this), this.sendToken.bind(this)];
55
-
56
- await this.runMiddlewares(middlewares, req, res);
57
- };
58
- }
59
- createAccount(req, res, next) {
60
- const { email, password, ...profileData } = req.$params;
61
- req.$ctx
62
- .call('auth.account.findByEmail', { email })
63
- .then(webId => {
64
- if (!webId) {
65
- return req.$ctx.call('webid.create', profileData);
66
- } else {
67
- throw new Error('email.already.exists');
68
- }
69
- })
70
- .then(webId =>
71
- req.$ctx.call('ldp.resource.get', {
72
- resourceUri: webId,
73
- accept: MIME_TYPES.JSON,
74
- webId: 'system'
75
- })
76
- )
77
- .then(userData => {
78
- req.user = userData;
79
- req.user.webId = userData.id;
80
- req.user.newUser = true;
81
- return req.$ctx.call('auth.account.create', {
82
- email,
83
- password,
84
- webId: userData.id
85
- });
86
- })
87
- .then(accountData => {
88
- req.$ctx.emit('auth.registered', { webId: accountData['semapps:webId'], profileData, accountData });
89
- next();
90
- })
91
- .catch(e => this.sendError(res, e.message));
92
- }
93
- sendToken(req, res, next) {
94
- res.setHeader('Content-Type', 'application/json');
95
- res.end(JSON.stringify({ token: req.user.token, newUser: req.user.newUser }));
96
- next();
97
- }
98
- sendError(res, message, statusCode = 400) {
99
- res.writeHead(statusCode, message);
100
- res.end();
101
- }
102
- }
103
-
104
- module.exports = LocalConnector;
package/OidcConnector.js DELETED
@@ -1,57 +0,0 @@
1
- const { Issuer, Strategy } = require('openid-client');
2
- const Connector = require('./Connector');
3
-
4
- class OidcConnector extends Connector {
5
- constructor(settings) {
6
- const { issuer, clientId, clientSecret, ...otherSettings } = settings;
7
- super('oidc', {
8
- issuer,
9
- clientId,
10
- clientSecret,
11
- ...otherSettings
12
- });
13
- }
14
- async initialize() {
15
- this.issuer = await Issuer.discover(this.settings.issuer);
16
- let config = {
17
- client_id: this.settings.clientId,
18
- client_secret: this.settings.clientSecret,
19
- redirect_uri: this.settings.redirectUri
20
- };
21
- if (!config.client_secret) {
22
- config.token_endpoint_auth_method = 'none';
23
- }
24
- const client = new this.issuer.Client(config);
25
- const params = {
26
- // ... any authorization params override client properties
27
- // client_id defaults to client.client_id
28
- // redirect_uri defaults to client.redirect_uris[0]
29
- // response type defaults to client.response_types[0], then 'code'
30
- // scope defaults to 'openid'
31
- };
32
-
33
- this.passport.use(
34
- 'oidc',
35
- new Strategy(
36
- {
37
- client,
38
- params
39
- },
40
- (tokenset, userinfo, done) => {
41
- done(null, userinfo);
42
- }
43
- )
44
- );
45
- }
46
- globalLogout(req, res, next) {
47
- // Redirect using NodeJS HTTP
48
- res.writeHead(302, {
49
- Location: `${this.issuer.end_session_endpoint}?post_logout_redirect_uri=${encodeURIComponent(
50
- req.session.redirectUrl
51
- )}`
52
- });
53
- res.end();
54
- }
55
- }
56
-
57
- module.exports = OidcConnector;
package/services/auth.js DELETED
@@ -1,144 +0,0 @@
1
- const urlJoin = require('url-join');
2
- const { MIME_TYPES } = require('@semapps/mime-types');
3
- const AuthAccountService = require('./account');
4
- const AuthJWTService = require('./jwt');
5
- const OidcConnector = require('../OidcConnector');
6
- const CasConnector = require('../CasConnector');
7
- const LocalConnector = require('../LocalConnector');
8
-
9
- module.exports = {
10
- name: 'auth',
11
- settings: {
12
- baseUrl: null,
13
- accountsContainer: null,
14
- jwtPath: null,
15
- oidc: {
16
- issuer: null,
17
- clientId: null,
18
- clientSecret: null
19
- },
20
- cas: {
21
- url: null
22
- },
23
- selectProfileData: null,
24
- registrationAllowed: true
25
- },
26
- dependencies: ['api', 'webid'],
27
- async created() {
28
- const { baseUrl, accountsContainer, jwtPath, oidc, cas } = this.settings;
29
-
30
- await this.broker.createService(AuthJWTService, {
31
- settings: { jwtPath }
32
- });
33
-
34
- if (!oidc.issuer && !cas.url) {
35
- await this.broker.createService(AuthAccountService, {
36
- settings: {
37
- containerUri: accountsContainer || urlJoin(baseUrl, 'accounts')
38
- }
39
- });
40
- }
41
- },
42
- async started() {
43
- const { baseUrl, selectProfileData, oidc, cas } = this.settings;
44
-
45
- if (oidc.issuer) {
46
- this.connector = new OidcConnector({
47
- issuer: oidc.issuer,
48
- clientId: oidc.clientId,
49
- clientSecret: oidc.clientSecret,
50
- redirectUri: urlJoin(baseUrl, 'auth'),
51
- selectProfileData,
52
- findOrCreateProfile: this.findOrCreateProfile
53
- });
54
- } else if (cas.url) {
55
- this.connector = new CasConnector({
56
- casUrl: cas.url,
57
- selectProfileData,
58
- findOrCreateProfile: this.findOrCreateProfile
59
- });
60
- } else {
61
- this.connector = new LocalConnector();
62
- }
63
-
64
- await this.connector.initialize();
65
-
66
- await this.broker.call('api.addRoute', {
67
- route: {
68
- path: '/auth',
69
- use: this.connector.getRouteMiddlewares(true),
70
- aliases: {
71
- 'GET /logout': this.connector.logout(),
72
- 'GET /': this.connector.login(),
73
- 'POST /': this.connector.login()
74
- },
75
- onError(req, res, err) {
76
- console.error(err);
77
- }
78
- }
79
- });
80
-
81
- await this.broker.call('api.addRoute', {
82
- route: {
83
- path: '/auth/signup',
84
- use: this.connector.getRouteMiddlewares(false),
85
- aliases: {
86
- 'POST /': this.connector.signup()
87
- },
88
- onError(req, res, err) {
89
- console.error(err);
90
- }
91
- }
92
- });
93
- },
94
- methods: {
95
- async findOrCreateProfile(profileData, authData) {
96
- let webId = await this.broker.call(
97
- 'webid.findByEmail',
98
- { email: profileData.email },
99
- { meta: { webId: 'system' } }
100
- );
101
-
102
- const newUser = !webId;
103
-
104
- if (newUser) {
105
- if (!this.settings.registrationAllowed) {
106
- throw new Error('registration.not-allowed');
107
- }
108
- webId = await this.broker.call('webid.create', profileData);
109
- await this.broker.emit('auth.registered', { webId, profileData, authData });
110
- } else {
111
- await this.broker.call('webid.edit', profileData, { meta: { webId } });
112
- await this.broker.emit('auth.connected', { webId, profileData, authData });
113
- }
114
-
115
- return { webId, newUser };
116
- }
117
- },
118
- actions: {
119
- async authenticate(ctx) {
120
- const { route, req, res } = ctx.params;
121
- return await this.connector.authenticate(ctx, route, req, res);
122
- },
123
- async authorize(ctx) {
124
- const { route, req, res } = ctx.params;
125
- return await this.connector.authorize(ctx, route, req, res);
126
- },
127
- async impersonate(ctx) {
128
- const { webId } = ctx.params;
129
- const userData = await ctx.call('ldp.resource.get', {
130
- resourceUri: webId,
131
- accept: MIME_TYPES.JSON,
132
- webId: 'system'
133
- });
134
- return await ctx.call('auth.jwt.generateToken', {
135
- payload: {
136
- webId,
137
- email: userData['foaf:email'],
138
- name: userData['foaf:name'],
139
- familyName: userData['foaf:familyName']
140
- }
141
- });
142
- }
143
- }
144
- };