@semapps/auth 0.4.0-alpha.8 → 0.4.0-rc.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
@@ -4,5 +4,6 @@ module.exports = {
4
4
  AuthOIDCService: require('./services/auth.oidc'),
5
5
  AuthAccountService: require('./services/account'),
6
6
  AuthJWTService: require('./services/jwt'),
7
- AuthMigrationService: require('./services/migration')
7
+ AuthMigrationService: require('./services/migration'),
8
+ AuthMailService: require('./services/mail')
8
9
  };
package/mixins/auth.js CHANGED
@@ -1,7 +1,8 @@
1
+ const passport = require('passport');
2
+ const { Errors: E } = require('moleculer-web');
3
+ const { TripleStoreAdapter } = require('@semapps/triplestore');
1
4
  const AuthAccountService = require('../services/account');
2
5
  const AuthJWTService = require('../services/jwt');
3
- const { Errors: E } = require('moleculer-web');
4
- const passport = require('passport');
5
6
 
6
7
  const AuthMixin = {
7
8
  settings: {
@@ -9,18 +10,21 @@ const AuthMixin = {
9
10
  jwtPath: null,
10
11
  registrationAllowed: true,
11
12
  reservedUsernames: [],
12
- webIdSelection: []
13
+ webIdSelection: [],
14
+ accountSelection: [],
15
+ accountsDataset: 'settings'
13
16
  },
14
17
  dependencies: ['api', 'webid'],
15
18
  async created() {
16
- const { jwtPath, reservedUsernames } = this.settings;
19
+ const { jwtPath, reservedUsernames, accountsDataset } = this.settings;
17
20
 
18
21
  await this.broker.createService(AuthJWTService, {
19
22
  settings: { jwtPath }
20
23
  });
21
24
 
22
25
  await this.broker.createService(AuthAccountService, {
23
- settings: { reservedUsernames }
26
+ settings: { reservedUsernames },
27
+ adapter: new TripleStoreAdapter({ type: 'AuthAccount', dataset: accountsDataset })
24
28
  });
25
29
  },
26
30
  async started() {
@@ -34,7 +38,7 @@ const AuthMixin = {
34
38
  done(null, user);
35
39
  });
36
40
 
37
- this.strategy = this.getStrategy();
41
+ this.strategy = await this.getStrategy();
38
42
 
39
43
  this.passport.use(this.passportId, this.strategy);
40
44
 
@@ -61,7 +65,7 @@ const AuthMixin = {
61
65
  return Promise.reject(new E.UnAuthorizedError(E.ERR_INVALID_TOKEN));
62
66
  }
63
67
  } else {
64
- // No token, anonymous error
68
+ // No token
65
69
  ctx.meta.webId = 'anon';
66
70
  return Promise.resolve(null);
67
71
  }
@@ -108,6 +112,15 @@ const AuthMixin = {
108
112
  } else {
109
113
  return data;
110
114
  }
115
+ },
116
+ pickAccountData(data) {
117
+ if (this.settings.accountSelection.length > 0) {
118
+ return Object.fromEntries(
119
+ this.settings.accountSelection.filter(key => key in data).map(key => [key, data[key]])
120
+ );
121
+ } else {
122
+ return data || {};
123
+ }
111
124
  }
112
125
  }
113
126
  };
@@ -32,25 +32,33 @@ const AuthSSOMixin = {
32
32
  newUser = false;
33
33
 
34
34
  // TODO update account with recent information
35
- // await this.broker.call('webid.edit', profileData, { meta: { webId } });
35
+ // await ctx.call('webid.edit', profileData, { meta: { webId } });
36
36
 
37
- await this.broker.emit('auth.connected', { webId, accountData });
37
+ ctx.emit('auth.connected', { webId, accountData, ssoData }, { meta: { webId: null, dataset: null } });
38
38
  } else {
39
39
  if (!this.settings.registrationAllowed) {
40
40
  throw new Error('registration.not-allowed');
41
41
  }
42
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 });
43
+ accountData = await ctx.call('auth.account.create', {
44
+ uuid: profileData.uuid,
45
+ email: profileData.email,
46
+ username: profileData.username
47
+ });
48
+ webId = await ctx.call('webid.create', this.pickWebIdData({ nick: accountData.username, ...profileData }));
45
49
  newUser = true;
46
50
 
47
51
  // Link the webId with the account
48
52
  await ctx.call('auth.account.attachWebId', { accountUri: accountData['@id'], webId });
49
53
 
50
- ctx.emit('auth.registered', { webId, profileData, accountData }, { meta: { webId: null, dataset: null } });
54
+ ctx.emit(
55
+ 'auth.registered',
56
+ { webId, profileData, accountData, ssoData },
57
+ { meta: { webId: null, dataset: null } }
58
+ );
51
59
  }
52
60
 
53
- const token = await ctx.call('auth.jwt.generateToken', { payload: { webId: accountData.webId } });
61
+ const token = await ctx.call('auth.jwt.generateToken', { payload: { webId } });
54
62
 
55
63
  return { token, newUser };
56
64
  }
package/package.json CHANGED
@@ -1,26 +1,29 @@
1
1
  {
2
2
  "name": "@semapps/auth",
3
- "version": "0.4.0-alpha.8",
3
+ "version": "0.4.0-rc.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.4.0-alpha.8",
9
- "@semapps/triplestore": "0.4.0-alpha.8",
8
+ "@semapps/mime-types": "0.4.0-rc.0",
9
+ "@semapps/triplestore": "0.4.0-rc.0",
10
10
  "bcrypt": "^5.0.1",
11
11
  "express-session": "^1.17.0",
12
12
  "jsonwebtoken": "^8.5.1",
13
13
  "moleculer": "^0.14.17",
14
14
  "moleculer-db": "^0.8.16",
15
+ "moleculer-mail": "^1.2.5",
15
16
  "moleculer-web": "^0.10.0-beta1",
17
+ "node-sass": "^7.0.1",
16
18
  "openid-client": "^4.7.4",
17
19
  "passport": "^0.4.1",
18
- "passport-cas2": "0.0.11",
20
+ "passport-cas2": "0.0.12",
19
21
  "passport-local": "^1.0.0",
22
+ "pug": "^3.0.2",
20
23
  "url-join": "^4.0.1"
21
24
  },
22
25
  "publishConfig": {
23
26
  "access": "public"
24
27
  },
25
- "gitHead": "0ba36915b1f687a3bb4c4bcd197336c7150ad7af"
28
+ "gitHead": "d018fd3a123fe3a9c5b0120c6718f583c7c7c55d"
26
29
  }
@@ -1,6 +1,7 @@
1
1
  const bcrypt = require('bcrypt');
2
2
  const DbService = require('moleculer-db');
3
3
  const { TripleStoreAdapter } = require('@semapps/triplestore');
4
+ const crypto = require('crypto');
4
5
 
5
6
  module.exports = {
6
7
  name: 'auth.account',
@@ -8,36 +9,41 @@ module.exports = {
8
9
  adapter: new TripleStoreAdapter({ type: 'AuthAccount', dataset: 'settings' }),
9
10
  settings: {
10
11
  idField: '@id',
11
- reservedUsernames: []
12
+ reservedUsernames: ['relay']
12
13
  },
13
14
  dependencies: ['triplestore'],
14
15
  actions: {
15
16
  async create(ctx) {
16
- let { uuid, username, password, email, webId } = ctx.params;
17
+ let { uuid, username, password, email, webId, ...rest } = ctx.params;
17
18
  const hashedPassword = password ? await this.hashPassword(password) : undefined;
18
19
 
19
- email = email.toLowerCase();
20
+ email = email && email.toLowerCase();
20
21
 
21
- const emailExists = await ctx.call('auth.account.emailExists', { email });
22
+ const emailExists = !email ? false : await ctx.call('auth.account.emailExists', { email });
22
23
  if (emailExists) {
23
24
  throw new Error('email.already.exists');
24
25
  }
25
26
 
26
27
  if (username) {
27
- await this.isValidUsername(ctx, username);
28
- } else {
28
+ if (!ctx.meta.isSystemCall) await this.isValidUsername(ctx, username);
29
+ } else if (email) {
29
30
  // If username is not provided, find an username based on the email
31
+ const usernameFromEmail = email.split('@')[0].toLowerCase();
30
32
  let usernameValid = false,
31
- i = 1;
33
+ i = 0;
32
34
  do {
35
+ username = i === 0 ? usernameFromEmail : usernameFromEmail + i;
36
+ try {
37
+ usernameValid = await this.isValidUsername(ctx, username);
38
+ } catch (e) {
39
+ // Do nothing, the loop will continue
40
+ }
33
41
  i++;
34
- username = email.split('@')[0].toLowerCase();
35
- if (i > 2) username += i;
36
- usernameValid = await this.isValidUsername(ctx, username);
37
- } while (usernameValid);
38
- }
42
+ } while (!usernameValid);
43
+ } else throw new Error('you must provide at least a username or an email address');
39
44
 
40
45
  return await this._create(ctx, {
46
+ ...rest,
41
47
  uuid,
42
48
  username,
43
49
  email,
@@ -87,6 +93,11 @@ module.exports = {
87
93
  const accounts = await this._find(ctx, { query: { webId } });
88
94
  return accounts.length > 0 ? accounts[0] : null;
89
95
  },
96
+ async findByEmail(ctx) {
97
+ const { email } = ctx.params;
98
+ const accounts = await this._find(ctx, { query: { email } });
99
+ return accounts.length > 0 ? accounts[0] : null;
100
+ },
90
101
  async setPassword(ctx) {
91
102
  const { webId, password } = ctx.params;
92
103
  const hashedPassword = await this.hashPassword(password);
@@ -96,12 +107,78 @@ module.exports = {
96
107
  '@id': account['@id'],
97
108
  hashedPassword
98
109
  });
110
+ },
111
+ async setNewPassword(ctx) {
112
+ const { webId, token, password } = ctx.params;
113
+ const hashedPassword = await this.hashPassword(password);
114
+ const account = await ctx.call('auth.account.findByWebId', { webId });
115
+
116
+ if (account.resetPasswordToken !== token) {
117
+ throw new Error('auth.password.invalid_reset_token');
118
+ }
119
+
120
+ return await this._update(ctx, {
121
+ '@id': account['@id'],
122
+ hashedPassword,
123
+ resetPasswordToken: undefined
124
+ });
125
+ },
126
+ async generateResetPasswordToken(ctx) {
127
+ const { webId } = ctx.params;
128
+ const resetPasswordToken = await this.generateResetPasswordToken();
129
+ const account = await ctx.call('auth.account.findByWebId', { webId });
130
+
131
+ await this._update(ctx, {
132
+ '@id': account['@id'],
133
+ resetPasswordToken
134
+ });
135
+
136
+ return resetPasswordToken;
137
+ },
138
+ async findSettingsByWebId(ctx) {
139
+ const { webId } = ctx.meta;
140
+ const account = await ctx.call('auth.account.findByWebId', { webId });
141
+
142
+ return {
143
+ email: account['email'],
144
+ preferredLocale: account['preferredLocale']
145
+ };
146
+ },
147
+ async updateAccountSettings(ctx) {
148
+ const { currentPassword, email, newPassword } = ctx.params;
149
+ const { webId } = ctx.meta;
150
+ const account = await ctx.call('auth.account.findByWebId', { webId });
151
+ const passwordMatch = await this.comparePassword(currentPassword, account.hashedPassword);
152
+ let params = {};
153
+
154
+ if (!passwordMatch) {
155
+ throw new Error('auth.account.invalid_password');
156
+ }
157
+
158
+ if (newPassword) {
159
+ const hashedPassword = await this.hashPassword(newPassword);
160
+ params = { ...params, hashedPassword };
161
+ }
162
+
163
+ if (email !== account['email']) {
164
+ const existing = await ctx.call('auth.account.findByEmail', { email });
165
+ if (existing) {
166
+ throw new Error('email.already.exists');
167
+ }
168
+
169
+ params = { ...params, email };
170
+ }
171
+
172
+ return await this._update(ctx, {
173
+ '@id': account['@id'],
174
+ ...params
175
+ });
99
176
  }
100
177
  },
101
178
  methods: {
102
179
  async isValidUsername(ctx, username) {
103
180
  // Ensure the username has no space or special characters
104
- if (!/^[a-z0-9\-_.]+$/.exec(username)) {
181
+ if (!/^[a-z0-9\-+_.]+$/.exec(username)) {
105
182
  throw new Error('username.invalid');
106
183
  }
107
184
 
@@ -110,11 +187,13 @@ module.exports = {
110
187
  throw new Error('username.already.exists');
111
188
  }
112
189
 
113
- // Ensure email or username doesn't already exist
190
+ // Ensure username doesn't already exist
114
191
  const usernameExists = await ctx.call('auth.account.usernameExists', { username });
115
192
  if (usernameExists) {
116
193
  throw new Error('username.already.exists');
117
194
  }
195
+
196
+ return true;
118
197
  },
119
198
  async hashPassword(password) {
120
199
  return new Promise((resolve, reject) => {
@@ -137,6 +216,16 @@ module.exports = {
137
216
  }
138
217
  });
139
218
  });
219
+ },
220
+ async generateResetPasswordToken() {
221
+ return new Promise(resolve => {
222
+ crypto.randomBytes(32, function(ex, buf) {
223
+ if (ex) {
224
+ reject(ex);
225
+ }
226
+ resolve(buf.toString('hex'));
227
+ });
228
+ });
140
229
  }
141
230
  }
142
231
  };
@@ -2,6 +2,7 @@ const { Strategy } = require('passport-local');
2
2
  const AuthMixin = require('../mixins/auth');
3
3
  const sendToken = require('../middlewares/sendToken');
4
4
  const { MoleculerError } = require('moleculer').Errors;
5
+ const AuthMailService = require('../services/mail');
5
6
 
6
7
  const AuthLocalService = {
7
8
  name: 'auth',
@@ -11,18 +12,43 @@ const AuthLocalService = {
11
12
  jwtPath: null,
12
13
  registrationAllowed: true,
13
14
  reservedUsernames: [],
14
- webIdSelection: []
15
+ webIdSelection: [],
16
+ accountSelection: [],
17
+ mail: {
18
+ from: null,
19
+ transport: {
20
+ host: null,
21
+ port: null
22
+ },
23
+ defaults: {
24
+ locale: null,
25
+ frontUrl: null
26
+ }
27
+ }
15
28
  },
16
- created() {
29
+ async created() {
30
+ const { mail } = this.settings;
31
+
17
32
  this.passportId = 'local';
33
+
34
+ await this.broker.createService(AuthMailService, {
35
+ settings: {
36
+ ...mail
37
+ }
38
+ });
18
39
  },
19
40
  actions: {
20
41
  async signup(ctx) {
21
- const { username, email, password, ...otherData } = ctx.params;
42
+ const { username, email, password, ...rest } = ctx.params;
22
43
 
23
- let accountData = await ctx.call('auth.account.create', { username, email, password });
44
+ let accountData = await ctx.call('auth.account.create', {
45
+ username,
46
+ email,
47
+ password,
48
+ ...this.pickAccountData(rest)
49
+ });
24
50
 
25
- const profileData = { nick: username, email, ...otherData };
51
+ const profileData = { nick: username, email, ...rest };
26
52
  const webId = await ctx.call('webid.create', this.pickWebIdData(profileData));
27
53
 
28
54
  // Link the webId with the account
@@ -44,6 +70,33 @@ const AuthLocalService = {
44
70
  const token = await ctx.call('auth.jwt.generateToken', { payload: { webId: accountData.webId } });
45
71
 
46
72
  return { token, webId: accountData.webId, newUser: true };
73
+ },
74
+ async resetPassword(ctx) {
75
+ const { email } = ctx.params;
76
+
77
+ const account = await ctx.call('auth.account.findByEmail', { email });
78
+
79
+ if (!account) {
80
+ throw new Error('email.not.exists');
81
+ }
82
+
83
+ const token = await ctx.call('auth.account.generateResetPasswordToken', { webId: account.webId });
84
+
85
+ await ctx.call('auth.mail.sendResetPasswordEmail', {
86
+ account,
87
+ token
88
+ });
89
+ },
90
+ async setNewPassword(ctx) {
91
+ const { email, token, password } = ctx.params;
92
+
93
+ const account = await ctx.call('auth.account.findByEmail', { email });
94
+
95
+ if (!account) {
96
+ throw new Error('email.not.exists');
97
+ }
98
+
99
+ await ctx.call('auth.account.setNewPassword', { webId: account.webId, token, password });
47
100
  }
48
101
  },
49
102
  methods: {
@@ -83,11 +136,35 @@ const AuthLocalService = {
83
136
  }
84
137
  };
85
138
 
139
+ const resetPasswordRoute = {
140
+ path: '/auth/reset_password',
141
+ aliases: {
142
+ 'POST /': 'auth.resetPassword'
143
+ }
144
+ };
145
+ const setNewPasswordRoute = {
146
+ path: '/auth/new_password',
147
+ aliases: {
148
+ 'POST /': 'auth.setNewPassword'
149
+ }
150
+ };
151
+
152
+ const accountSettingsRoute = {
153
+ path: '/auth/account',
154
+ aliases: {
155
+ 'GET /': 'auth.account.findSettingsByWebId',
156
+ 'POST /': 'auth.account.updateAccountSettings'
157
+ },
158
+ authorization: true
159
+ };
160
+
161
+ const routes = [loginRoute, resetPasswordRoute, setNewPasswordRoute, accountSettingsRoute];
162
+
86
163
  if (this.settings.registrationAllowed) {
87
- return [loginRoute, signupRoute];
88
- } else {
89
- return [loginRoute];
164
+ return [...routes, signupRoute];
90
165
  }
166
+
167
+ return routes;
91
168
  }
92
169
  }
93
170
  };
@@ -1,5 +1,8 @@
1
1
  const urlJoin = require('url-join');
2
- const { Issuer, Strategy } = require('openid-client');
2
+ const { Issuer, Strategy, custom } = require('openid-client');
3
+ custom.setHttpOptionsDefaults({
4
+ timeout: 10000
5
+ });
3
6
  const AuthSSOMixin = require('../mixins/auth.sso');
4
7
 
5
8
  const AuthOIDCService = {
@@ -21,11 +24,12 @@ const AuthOIDCService = {
21
24
  },
22
25
  async created() {
23
26
  this.passportId = 'oidc';
24
- this.issuer = await Issuer.discover(this.settings.issuer);
25
27
  },
26
28
  methods: {
27
- getStrategy() {
28
- const client = new this.issuer.Client({
29
+ async getStrategy() {
30
+ const issuer = await Issuer.discover(this.settings.issuer);
31
+
32
+ const client = new issuer.Client({
29
33
  client_id: this.settings.clientId,
30
34
  client_secret: this.settings.clientSecret,
31
35
  redirect_uri: urlJoin(this.settings.baseUrl, 'auth'),
@@ -0,0 +1,49 @@
1
+ const MailService = require('moleculer-mail');
2
+ const path = require('path');
3
+
4
+ module.exports = {
5
+ name: 'auth.mail',
6
+ mixins: [MailService],
7
+ settings: {
8
+ defaults: {
9
+ locale: 'en',
10
+ frontUrl: null
11
+ },
12
+ templateFolder: path.join(__dirname, '../templates'),
13
+ from: null,
14
+ transport: null
15
+ },
16
+ actions: {
17
+ async sendResetPasswordEmail(ctx) {
18
+ const { account, token } = ctx.params;
19
+
20
+ await this.actions.send(
21
+ {
22
+ to: account.email,
23
+ template: 'reset-password',
24
+ locale: this.getTemplateLocale(account.preferredLocale || this.settings.defaults.locale),
25
+ data: {
26
+ account,
27
+ token,
28
+ frontUrl: account.preferredFrontUrl || this.settings.defaults.frontUrl
29
+ }
30
+ },
31
+ {
32
+ parentCtx: ctx
33
+ }
34
+ );
35
+ }
36
+ },
37
+ methods: {
38
+ getTemplateLocale(userLocale) {
39
+ switch (userLocale) {
40
+ case 'fr':
41
+ return 'fr-FR';
42
+ case 'en':
43
+ return 'en-EN';
44
+ default:
45
+ return 'en-EN';
46
+ }
47
+ }
48
+ }
49
+ };
@@ -1,4 +1,5 @@
1
1
  const { MIME_TYPES } = require('@semapps/mime-types');
2
+ const { getSlugFromUri } = require('@semapps/ldp');
2
3
 
3
4
  module.exports = {
4
5
  name: 'auth.migration',
@@ -13,7 +14,7 @@ module.exports = {
13
14
  try {
14
15
  await ctx.call('auth.account.create', {
15
16
  email: user[emailPredicate],
16
- username: user[usernamePredicate],
17
+ username: usernamePredicate ? user[usernamePredicate] : getSlugFromUri(user.id),
17
18
  webId: user.id
18
19
  });
19
20
  } catch (e) {
@@ -0,0 +1 @@
1
+ Password reset for {{account.username}} account
@@ -0,0 +1,7 @@
1
+ Hello {{account.username}},
2
+
3
+ In order to reset your password, please click on the link below:
4
+
5
+ {{frontUrl}}/login?new_password=true&token={{token}}
6
+
7
+ If you did not request a password reset, please ignore this message.
@@ -0,0 +1 @@
1
+ Réinitialisation du mot de passe du compte {{account.username}}
@@ -0,0 +1,7 @@
1
+ Bonjour {{account.username}},
2
+
3
+ Pour réinitialiser votre mot de passe, veuillez cliquer sur le lien ci-dessous:
4
+
5
+ {{frontUrl}}/login?new_password=true&token={{token}}
6
+
7
+ Si vous n'avez pas fait une telle demande, vous pouvez ignorer ce message.