@punch-in/strapi-admin 1.0.9 → 1.2.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/admin/src/containers/AuthPage/index.js +53 -0
- package/config/routes.json +18 -0
- package/controllers/authentication.js +64 -78
- package/package.json +1 -1
- package/services/auth.js +18 -0
|
@@ -87,6 +87,59 @@ const AuthPage = ({ hasAdmin, setHasAdmin }) => {
|
|
|
87
87
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
88
|
}, [authType]);
|
|
89
89
|
|
|
90
|
+
// Lands here after a full-page round trip through an SSO provider
|
|
91
|
+
// (controllers/authentication.js's ssoCallback). The token in the URL is
|
|
92
|
+
// a normal admin JWT for an *existing* admin the provider's verified
|
|
93
|
+
// email matched - nothing was created. Fetch the profile it belongs to,
|
|
94
|
+
// persist it the same way a password login does, then drop both params
|
|
95
|
+
// from the URL so the JWT doesn't linger in browser history.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const ssoToken = query.get('ssoToken');
|
|
98
|
+
const ssoError = query.get('ssoError');
|
|
99
|
+
|
|
100
|
+
if (!ssoToken && !ssoError) return;
|
|
101
|
+
|
|
102
|
+
window.history.replaceState(null, '', window.location.pathname);
|
|
103
|
+
|
|
104
|
+
if (ssoError) {
|
|
105
|
+
const messages = {
|
|
106
|
+
access_denied: 'Sign-in was cancelled.',
|
|
107
|
+
no_admin_account: 'No admin account exists for that email address.',
|
|
108
|
+
email_not_verified: "That provider didn't return a verified email address.",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
strapi.notification.toggle({
|
|
112
|
+
type: 'warning',
|
|
113
|
+
message: messages[ssoError] || 'Single sign-on failed. Please try again.',
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const completeSsoLogin = async () => {
|
|
119
|
+
try {
|
|
120
|
+
const {
|
|
121
|
+
data: { data: user },
|
|
122
|
+
} = await axios.get(`${strapi.backendURL}/admin/users/me`, {
|
|
123
|
+
headers: { Authorization: `Bearer ${ssoToken}` },
|
|
124
|
+
cancelToken: source.token,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
auth.setToken(ssoToken, false);
|
|
128
|
+
auth.setUserInfo(user, false);
|
|
129
|
+
|
|
130
|
+
push('/');
|
|
131
|
+
} catch (err) {
|
|
132
|
+
strapi.notification.toggle({
|
|
133
|
+
type: 'warning',
|
|
134
|
+
message: 'Single sign-on failed. Please try again.',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
completeSsoLogin();
|
|
140
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
90
143
|
const handleChange = ({ target: { name, value } }) => {
|
|
91
144
|
dispatch({
|
|
92
145
|
type: 'ON_CHANGE',
|
package/config/routes.json
CHANGED
|
@@ -83,6 +83,24 @@
|
|
|
83
83
|
"path": "/reset-password",
|
|
84
84
|
"handler": "authentication.resetPassword"
|
|
85
85
|
},
|
|
86
|
+
{
|
|
87
|
+
"method": "GET",
|
|
88
|
+
"path": "/providers",
|
|
89
|
+
"handler": "authentication.ssoProviders"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"method": "GET",
|
|
93
|
+
"path": "/connect/:provider",
|
|
94
|
+
"handler": "authentication.ssoConnect"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"method": "POST",
|
|
98
|
+
"path": "/sso/verify",
|
|
99
|
+
"handler": "authentication.ssoVerify",
|
|
100
|
+
"config": {
|
|
101
|
+
"policies": []
|
|
102
|
+
}
|
|
103
|
+
},
|
|
86
104
|
{
|
|
87
105
|
"method": "GET",
|
|
88
106
|
"path": "/webhooks",
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const passport = require('koa-passport');
|
|
4
4
|
const compose = require('koa-compose');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const axios = require('axios');
|
|
5
7
|
|
|
6
8
|
const {
|
|
7
9
|
validateRegistrationInput,
|
|
@@ -179,96 +181,80 @@ module.exports = {
|
|
|
179
181
|
};
|
|
180
182
|
},
|
|
181
183
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
icon: 'https://developers.google.com/identity/images/g-logo.png',
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
uid: 'github',
|
|
193
|
-
displayName: 'GitHub',
|
|
194
|
-
icon: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png',
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
uid: 'microsoft',
|
|
198
|
-
displayName: 'Microsoft',
|
|
199
|
-
icon: 'https://upload.wikimedia.org/wikipedia/commons/4/44/Microsoft_logo.svg',
|
|
200
|
-
},
|
|
201
|
-
{
|
|
202
|
-
uid: 'facebook',
|
|
203
|
-
displayName: 'Facebook',
|
|
204
|
-
icon: 'https://upload.wikimedia.org/wikipedia/commons/5/51/Facebook_f_logo_%282019%29.svg',
|
|
205
|
-
},
|
|
206
|
-
{
|
|
207
|
-
uid: 'linkedin',
|
|
208
|
-
displayName: 'LinkedIn',
|
|
209
|
-
icon: 'https://upload.wikimedia.org/wikipedia/commons/c/ca/LinkedIn_logo_initials.png',
|
|
210
|
-
},
|
|
211
|
-
];
|
|
184
|
+
// Admin SSO now goes through the central Lambda (api.punch-in.co.uk),
|
|
185
|
+
// registered once per provider instead of once per tenant - see
|
|
186
|
+
// stripe-charge/strapi-charge/{sso.js,controllers/ssoController.js}.
|
|
187
|
+
// This instance's only remaining jobs are: (1) tell the login page which
|
|
188
|
+
// provider buttons to show, (2) hand the browser off with its own
|
|
189
|
+
// subdomain attached, (3) later, verify a provider-confirmed email
|
|
190
|
+
// against *this* tenant's admin_users - never creating one.
|
|
212
191
|
|
|
213
|
-
|
|
192
|
+
async ssoProviders(ctx) {
|
|
193
|
+
try {
|
|
194
|
+
const { data } = await axios.get(`${getCentralApiBase()}/sso/auth/providers`, {
|
|
195
|
+
timeout: 5000,
|
|
196
|
+
});
|
|
197
|
+
ctx.body = data;
|
|
214
198
|
} catch (err) {
|
|
215
|
-
|
|
199
|
+
strapi.log.error(`Failed to fetch SSO providers: ${err.message}`);
|
|
200
|
+
ctx.body = [];
|
|
216
201
|
}
|
|
217
202
|
},
|
|
218
203
|
|
|
219
|
-
async
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
// This is where you would implement the actual OAuth flow
|
|
224
|
-
// For now, we'll just redirect to the provider's OAuth URL
|
|
225
|
-
const providerUrls = {
|
|
226
|
-
google: `https://accounts.google.com/oauth/authorize?client_id=${process.env.GOOGLE_CLIENT_ID}&redirect_uri=${process.env.GOOGLE_REDIRECT_URI}&scope=openid%20email%20profile&response_type=code`,
|
|
227
|
-
github: `https://github.com/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&redirect_uri=${process.env.GITHUB_REDIRECT_URI}&scope=user:email`,
|
|
228
|
-
microsoft: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${process.env.MICROSOFT_CLIENT_ID}&redirect_uri=${process.env.MICROSOFT_REDIRECT_URI}&scope=openid%20email%20profile&response_type=code`,
|
|
229
|
-
facebook: `https://www.facebook.com/v18.0/dialog/oauth?client_id=${process.env.FACEBOOK_CLIENT_ID}&redirect_uri=${process.env.FACEBOOK_REDIRECT_URI}&scope=email%20public_profile&response_type=code`,
|
|
230
|
-
linkedin: `https://www.linkedin.com/oauth/v2/authorization?client_id=${process.env.LINKEDIN_CLIENT_ID}&redirect_uri=${process.env.LINKEDIN_REDIRECT_URI}&scope=r_liteprofile%20r_emailaddress&response_type=code`,
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const authUrl = providerUrls[provider];
|
|
234
|
-
|
|
235
|
-
if (!authUrl) {
|
|
236
|
-
return ctx.badRequest(null, 'Provider not supported');
|
|
237
|
-
}
|
|
204
|
+
async ssoConnect(ctx) {
|
|
205
|
+
const { provider: providerUid } = ctx.params;
|
|
206
|
+
const subdomain = process.env.SUBDOMAIN;
|
|
238
207
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
ctx.badRequest(null, 'An error occurred while connecting to provider');
|
|
208
|
+
if (!subdomain) {
|
|
209
|
+
return ctx.badRequest('SUBDOMAIN is not configured on this Strapi instance');
|
|
242
210
|
}
|
|
211
|
+
|
|
212
|
+
const url = new URL(`${getCentralApiBase()}/sso/auth/${providerUid}`);
|
|
213
|
+
url.searchParams.set('subdomain', subdomain);
|
|
214
|
+
|
|
215
|
+
ctx.redirect(url.toString());
|
|
243
216
|
},
|
|
244
217
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
218
|
+
// POST /admin/sso/verify - called server-to-server by the central Lambda
|
|
219
|
+
// once it has a provider-verified email, authenticated the same way
|
|
220
|
+
// api/provider-connections' callback already is (X-Sync-Secret, constant-
|
|
221
|
+
// time compare). Read-only: an unmatched or inactive email is rejected,
|
|
222
|
+
// never used to create or activate an admin account.
|
|
223
|
+
async ssoVerify(ctx) {
|
|
224
|
+
const expected = process.env.SYNC_SECRET;
|
|
225
|
+
if (!expected) {
|
|
226
|
+
return ctx.internalServerError('SYNC_SECRET is not configured on this Strapi instance');
|
|
227
|
+
}
|
|
252
228
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
} catch (err) {
|
|
257
|
-
ctx.badRequest(null, 'An error occurred while retrieving provider login options');
|
|
229
|
+
const provided = ctx.request.header['x-sync-secret'];
|
|
230
|
+
if (!secretsMatch(provided, expected)) {
|
|
231
|
+
return ctx.forbidden('Invalid sync secret');
|
|
258
232
|
}
|
|
259
|
-
},
|
|
260
233
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
} catch (err) {
|
|
271
|
-
ctx.badRequest(null, 'An error occurred while updating provider login options');
|
|
234
|
+
const { email, provider } = ctx.request.body || {};
|
|
235
|
+
const user = await strapi.admin.services.auth.findActiveAdminByVerifiedEmail(email);
|
|
236
|
+
|
|
237
|
+
if (!user) {
|
|
238
|
+
strapi.eventHub.emit('admin.auth.error', {
|
|
239
|
+
error: new Error('No matching active admin account for SSO email'),
|
|
240
|
+
provider: provider || 'sso',
|
|
241
|
+
});
|
|
242
|
+
return ctx.notFound('No matching active admin account');
|
|
272
243
|
}
|
|
244
|
+
|
|
245
|
+
strapi.eventHub.emit('admin.auth.success', { user, provider: provider || 'sso' });
|
|
246
|
+
|
|
247
|
+
ctx.body = { token: strapi.admin.services.token.createJwtToken(user) };
|
|
273
248
|
},
|
|
274
249
|
};
|
|
250
|
+
|
|
251
|
+
function getCentralApiBase() {
|
|
252
|
+
return (process.env.PUNCHIN_INTEGRATIONS_API_BASE || 'https://api.punch-in.co.uk').replace(/\/$/, '');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function secretsMatch(provided, expected) {
|
|
256
|
+
if (!provided || !expected) return false;
|
|
257
|
+
const a = Buffer.from(provided);
|
|
258
|
+
const b = Buffer.from(expected);
|
|
259
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
260
|
+
}
|
package/package.json
CHANGED
package/services/auth.js
CHANGED
|
@@ -45,6 +45,23 @@ const checkCredentials = async ({ email, password }) => {
|
|
|
45
45
|
return [null, user];
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Look up an existing, active admin by a *verified* email address for SSO
|
|
50
|
+
* login. Deliberately read-only - unlike checkCredentials, this must never
|
|
51
|
+
* create, activate, or otherwise modify an admin_users row. An email with
|
|
52
|
+
* no matching account, or matching an inactive one, yields no session.
|
|
53
|
+
* @param {string} email - email already verified by the SSO provider
|
|
54
|
+
*/
|
|
55
|
+
const findActiveAdminByVerifiedEmail = async email => {
|
|
56
|
+
if (!email) return null;
|
|
57
|
+
|
|
58
|
+
const user = await strapi.query('user', 'admin').findOne({ email });
|
|
59
|
+
if (!user) return null;
|
|
60
|
+
if (!(user.isActive === true)) return null;
|
|
61
|
+
|
|
62
|
+
return user;
|
|
63
|
+
};
|
|
64
|
+
|
|
48
65
|
/**
|
|
49
66
|
* Send an email to the user if it exists or do nothing
|
|
50
67
|
* @param {Object} param params
|
|
@@ -106,6 +123,7 @@ const resetPassword = async ({ resetPasswordToken, password } = {}) => {
|
|
|
106
123
|
|
|
107
124
|
module.exports = {
|
|
108
125
|
checkCredentials,
|
|
126
|
+
findActiveAdminByVerifiedEmail,
|
|
109
127
|
validatePassword,
|
|
110
128
|
hashPassword,
|
|
111
129
|
forgotPassword,
|