strapi-plugin-magic-link-v5 5.5.8 β 6.0.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/CHANGELOG.md +740 -0
- package/README.md +662 -928
- package/dist/admin/LicenseGuard-BgLA9go3.mjs +495 -0
- package/dist/admin/LicenseGuard-ewCjpc17.js +513 -0
- package/dist/admin/de-B1FNErPH.mjs +673 -0
- package/dist/admin/de-e75OECdq.js +673 -0
- package/dist/admin/en-CABKGbxH.js +673 -0
- package/dist/admin/en-HqDOFBCc.mjs +673 -0
- package/dist/admin/es-DpWl0Fax.js +673 -0
- package/dist/admin/es-jb94jqPR.mjs +673 -0
- package/dist/admin/fr-BQhLPvYw.js +673 -0
- package/dist/admin/fr-BseH1wrr.mjs +673 -0
- package/dist/admin/index-6ot9j1cN.js +5268 -0
- package/dist/admin/index-B390rccl.js +622 -0
- package/dist/admin/index-BPGKtruf.mjs +5266 -0
- package/dist/admin/index-BaTIQtnA.js +3869 -0
- package/dist/admin/index-CmG-93yx.js +141 -0
- package/dist/admin/index-DXe_ge4v.mjs +620 -0
- package/dist/admin/index-Dv5PVT1E.mjs +3867 -0
- package/dist/admin/index-k240ls6p.mjs +142 -0
- package/dist/admin/index.js +3 -3
- package/dist/admin/index.mjs +1 -2
- package/dist/admin/pt-CPkaveti.js +673 -0
- package/dist/admin/pt-DhlSCJn2.mjs +673 -0
- package/dist/admin/useUnsavedChangesGuard-BVZ4Mg4E.js +225 -0
- package/dist/admin/useUnsavedChangesGuard-QnkiZTLP.mjs +224 -0
- package/dist/server/i18n/de.json +32 -1
- package/dist/server/i18n/en.json +32 -1
- package/dist/server/i18n/es.json +32 -1
- package/dist/server/i18n/fr.json +32 -1
- package/dist/server/i18n/pt.json +32 -1
- package/dist/server/index.js +7092 -5979
- package/dist/server/index.mjs +7114 -5979
- package/package.json +42 -41
- package/strapi-admin.js +3 -1
- package/strapi-server.js +3 -2
- package/dist/_chunks/LicenseGuard-CiyjFnrw.js +0 -54
- package/dist/_chunks/LicenseGuard-DJ5fq2UZ.mjs +0 -55
- package/dist/_chunks/de-BSOk5suQ.js +0 -454
- package/dist/_chunks/de-CwhYs3O3.mjs +0 -454
- package/dist/_chunks/en-DnvSpXvu.js +0 -454
- package/dist/_chunks/en-gXXgGDV1.mjs +0 -454
- package/dist/_chunks/es-B-tnwByt.js +0 -407
- package/dist/_chunks/es-DG5gW3RZ.mjs +0 -407
- package/dist/_chunks/fr-BKo8t7Fx.js +0 -407
- package/dist/_chunks/fr-BQn1OHOR.mjs +0 -407
- package/dist/_chunks/index-01GHNxSr.js +0 -307
- package/dist/_chunks/index-B9Ch8Jpe.mjs +0 -5066
- package/dist/_chunks/index-Bj-erY7g.mjs +0 -143
- package/dist/_chunks/index-CGxbhJfg.js +0 -142
- package/dist/_chunks/index-DF3Edydl.mjs +0 -330
- package/dist/_chunks/index-Dufv-daf.mjs +0 -305
- package/dist/_chunks/index-I72rBTkL.js +0 -332
- package/dist/_chunks/index-SCxv154G.mjs +0 -2904
- package/dist/_chunks/index-XYWLmmEs.js +0 -5068
- package/dist/_chunks/index-zzoovbM0.js +0 -2906
- package/dist/_chunks/pt-CjEkXBAz.js +0 -407
- package/dist/_chunks/pt-trziAOzT.mjs +0 -407
package/README.md
CHANGED
|
@@ -1,1109 +1,843 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Magic Link for Strapi 5
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Secure, modern, and user-friendly authentication using email-based magic links, OTP codes, and TOTP authenticators. Built for production with enterprise-grade security features.
|
|
3
|
+
Passwordless login for Strapi 5. Your users type their e-mail address, click the link in their inbox and get a normal Users & Permissions session, the same kind `/api/auth/local` creates. When you need more than a link, add a second factor: an e-mailed one-time code (OTP) or an authenticator app (TOTP) with backup codes.
|
|
6
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/strapi-plugin-magic-link-v5)
|
|
7
6
|
[](LICENSE)
|
|
8
|
-
[
|
|
114
|
-
|
|
115
|
-
### Create New Token
|
|
116
|
-
Simple modal to create tokens with custom TTL and context data.
|
|
117
|
-
|
|
118
|
-

|
|
119
|
-
|
|
120
|
-
### JWT Session Management
|
|
121
|
-
Monitor and manage all active JWT sessions across your application.
|
|
122
|
-
|
|
123
|
-

|
|
124
|
-
|
|
125
|
-
### IP Ban Management
|
|
126
|
-
Security feature to block suspicious IP addresses.
|
|
127
|
-
|
|
128
|
-

|
|
129
|
-
|
|
130
|
-
### Settings Interface
|
|
131
|
-
Comprehensive settings panel with modern UI.
|
|
132
|
-
|
|
133
|
-

|
|
134
|
-
|
|
135
|
-
### General Settings
|
|
136
|
-
Configure core functionality and authentication options.
|
|
137
|
-
|
|
138
|
-

|
|
139
|
-
|
|
140
|
-
---
|
|
141
|
-
|
|
142
|
-
## WhatsApp Integration (NEW!)
|
|
143
|
-
|
|
144
|
-
Magic Link now supports sending authentication links via **WhatsApp** - completely FREE!
|
|
145
|
-
|
|
146
|
-
### Why WhatsApp?
|
|
147
|
-
|
|
148
|
-
- **Higher Open Rates** - 98% open rate vs 20% for email
|
|
149
|
-
- **Instant Delivery** - No spam folders, no delays
|
|
150
|
-
- **User Preference** - Many users prefer messaging over email
|
|
151
|
-
- **FREE** - No additional costs, uses your own WhatsApp number
|
|
152
|
-
|
|
153
|
-
### Quick Setup
|
|
154
|
-
|
|
155
|
-
1. Navigate to **Admin Panel -> Magic Link -> WhatsApp**
|
|
156
|
-
2. Scan the QR code with your WhatsApp mobile app
|
|
157
|
-
3. Done! Your WhatsApp is connected
|
|
158
|
-
|
|
159
|
-
### How It Works
|
|
160
|
-
|
|
161
|
-
When a user requests a magic link and has a phone number on file, you can send the magic link via WhatsApp instead of email:
|
|
162
|
-
|
|
163
|
-
```javascript
|
|
164
|
-
// Send magic link via WhatsApp
|
|
165
|
-
await fetch('/api/magic-link/send-link', {
|
|
166
|
-
method: 'POST',
|
|
167
|
-
headers: { 'Content-Type': 'application/json' },
|
|
168
|
-
body: JSON.stringify({
|
|
169
|
-
email: 'user@example.com',
|
|
170
|
-
channel: 'whatsapp', // Send via WhatsApp
|
|
171
|
-
phoneNumber: '+491234567890' // Required for WhatsApp
|
|
172
|
-
})
|
|
173
|
-
});
|
|
174
|
-
```
|
|
7
|
+
[](https://strapi.io)
|
|
8
|
+
|
|
9
|
+
**Project site:** [schero94.github.io/strapi-plugin-magic-link-v5](https://schero94.github.io/strapi-plugin-magic-link-v5/)
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## Contents
|
|
14
|
+
|
|
15
|
+
- [What you get](#what-you-get)
|
|
16
|
+
- [Screenshots](#screenshots)
|
|
17
|
+
- [Requirements](#requirements)
|
|
18
|
+
- [Quick start (5 minutes)](#quick-start-5-minutes)
|
|
19
|
+
- [Frontend integration](#frontend-integration)
|
|
20
|
+
- [Second factors: e-mail OTP, TOTP and backup codes](#second-factors-e-mail-otp-totp-and-backup-codes)
|
|
21
|
+
- [Refresh mode (users-permissions sessions)](#refresh-mode-users-permissions-sessions)
|
|
22
|
+
- [Public API](#public-api)
|
|
23
|
+
- [Errors](#errors)
|
|
24
|
+
- [Admin API](#admin-api)
|
|
25
|
+
- [Permissions](#permissions)
|
|
26
|
+
- [Configuration reference](#configuration-reference)
|
|
27
|
+
- [E-mail templates](#e-mail-templates)
|
|
28
|
+
- [Security model](#security-model)
|
|
29
|
+
- [Running behind a proxy or in a cluster](#running-behind-a-proxy-or-in-a-cluster)
|
|
30
|
+
- [Migrating from strapi-plugin-passwordless](#migrating-from-strapi-plugin-passwordless)
|
|
31
|
+
- [Troubleshooting](#troubleshooting)
|
|
32
|
+
- [Upgrading from 5.x to 6.0.0](#upgrading-from-5x-to-600)
|
|
33
|
+
- [Languages, license and support](#languages-license-and-support)
|
|
34
|
+
|
|
35
|
+
## What you get
|
|
36
|
+
|
|
37
|
+
- **Magic links by e-mail.** One API call sends the link. Your landing page exchanges it for a JWT.
|
|
38
|
+
- **Optional second factors.**
|
|
39
|
+
- E-mail OTP after the link click.
|
|
40
|
+
- Authenticator apps (TOTP) as optional or required MFA: enrolment during sign-in, self-service for logged-in users, and one-time backup codes.
|
|
41
|
+
- TOTP-only login without an e-mail round trip.
|
|
42
|
+
- **Built for production.**
|
|
43
|
+
- Links are stored hashed, expire, and a single-use link is consumed atomically.
|
|
44
|
+
- Rate limits per IP and per address, IP bans, and an always-on limit for code checks.
|
|
45
|
+
- A JWT session list with revocation, also for users-permissions refresh sessions.
|
|
46
|
+
- **Works with your stack.** Users & Permissions (legacy JWTs and refresh-token sessions), the Strapi e-mail plugin, Magic Mail, Email Designer 5, and a `strapi-plugin-passwordless` compatibility mode.
|
|
47
|
+
- **Admin panel.**
|
|
48
|
+
- Pages for tokens, OTP codes, JWT sessions, IP bans, settings and license.
|
|
49
|
+
- Light and dark mode, translated into English, German, French, Spanish and Portuguese.
|
|
50
|
+
- Fine-grained admin permissions, enforced on the server.
|
|
51
|
+
|
|
52
|
+
Magic Link is **e-mail only**. WhatsApp and SMS delivery were removed in 6.0.0.
|
|
53
|
+
|
|
54
|
+
## Screenshots
|
|
55
|
+
|
|
56
|
+
All screenshots use synthetic data (`example.test` addresses and documentation IP ranges).
|
|
57
|
+
|
|
58
|
+
| Tokens | Create a token |
|
|
59
|
+
| ------------------------------------------------------------- | -------------------------------------------------------- |
|
|
60
|
+
|  |  |
|
|
61
|
+
|
|
62
|
+
| JWT sessions | IP bans |
|
|
63
|
+
| ---------------------------------------------------------------------- | ----------------------------------------------------------- |
|
|
64
|
+
|  |  |
|
|
65
|
+
|
|
66
|
+
| Settings: general | Settings: sign-in modes and MFA |
|
|
67
|
+
| ------------------------------------------------------ | -------------------------------------------------------- |
|
|
68
|
+
|  |  |
|
|
69
|
+
|
|
70
|
+
| License | Dark mode |
|
|
71
|
+
| --------------------------------- | ------------------------------------------------- |
|
|
72
|
+
|  |  |
|
|
73
|
+
|
|
74
|
+
On phones the tables turn into cards:
|
|
75
|
+
|
|
76
|
+
| Tokens on a phone | JWT sessions on a phone |
|
|
77
|
+
| ------------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
|
78
|
+
|  |  |
|
|
79
|
+
|
|
80
|
+
## Requirements
|
|
81
|
+
|
|
82
|
+
| Component | Version |
|
|
83
|
+
| ------------------- | ---------------------------------------------------------------------------------------- |
|
|
84
|
+
| Strapi | 5.24.0 or newer (tested on 5.61.0). Refresh mode with the httpOnly cookie needs 5.33.3+. |
|
|
85
|
+
| Node.js | 22 to 26 (the range Strapi 5.61 supports) |
|
|
86
|
+
| Users & Permissions | Installed (default in Strapi 5) |
|
|
87
|
+
| E-mail | A configured Strapi e-mail provider, or Magic Mail |
|
|
88
|
+
| Environment | `MAGIC_LINK_ENCRYPTION_KEY` and `MAGIC_LINK_OTP_PEPPER`, 32+ characters each (see below) |
|
|
89
|
+
|
|
90
|
+
## Quick start (5 minutes)
|
|
91
|
+
|
|
92
|
+
### 1. Install
|
|
175
93
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
Configure your WhatsApp message templates in **Settings -> Email & Messaging**:
|
|
179
|
-
|
|
180
|
-
**Available Variables:**
|
|
181
|
-
- `<%= URL %>` - Your frontend login URL
|
|
182
|
-
- `<%= CODE %>` - The magic link token
|
|
183
|
-
- `<%= APP_NAME %>` - Your app name
|
|
184
|
-
- `<%= EXPIRY_TEXT %>` - Token expiration text
|
|
185
|
-
|
|
186
|
-
**Example Template:**
|
|
94
|
+
```bash
|
|
95
|
+
npm install strapi-plugin-magic-link-v5
|
|
187
96
|
```
|
|
188
|
-
*<%= APP_NAME %> Login*
|
|
189
|
-
|
|
190
|
-
Click the link below to log in:
|
|
191
97
|
|
|
192
|
-
|
|
98
|
+
### 2. Set the two required secrets
|
|
193
99
|
|
|
194
|
-
|
|
100
|
+
The plugin refuses to start without them, also in development:
|
|
195
101
|
|
|
196
|
-
|
|
102
|
+
```bash
|
|
103
|
+
# .env, generate each value with:
|
|
104
|
+
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
105
|
+
MAGIC_LINK_ENCRYPTION_KEY=<64 hex characters>
|
|
106
|
+
MAGIC_LINK_OTP_PEPPER=<64 hex characters>
|
|
197
107
|
```
|
|
198
108
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
Configure where users land when clicking the magic link:
|
|
202
|
-
|
|
203
|
-
1. Go to **Settings -> Email & Messaging**
|
|
204
|
-
2. Set **Frontend Login URL** (e.g., `https://yourapp.com/auth/login`)
|
|
205
|
-
3. Magic links will now redirect to your frontend
|
|
206
|
-
|
|
207
|
-
### Integration with Magic-Mail
|
|
208
|
-
|
|
209
|
-
If you have both **Magic-Link** and **Magic-Mail** plugins installed:
|
|
210
|
-
|
|
211
|
-
- Magic-Link delegates WhatsApp functionality to Magic-Mail
|
|
212
|
-
- Only ONE WhatsApp connection needed (managed by Magic-Mail)
|
|
213
|
-
- Navigate to Magic-Mail for WhatsApp setup
|
|
214
|
-
- Unified WhatsApp management across both plugins
|
|
215
|
-
|
|
216
|
-
---
|
|
217
|
-
|
|
218
|
-
## Prerequisites
|
|
109
|
+
For a throwaway local setup you can set `MAGIC_LINK_ALLOW_DEV_SECRETS=true` instead. It is ignored when `NODE_ENV=production`.
|
|
219
110
|
|
|
220
|
-
|
|
111
|
+
### 3. Configure e-mail
|
|
221
112
|
|
|
222
|
-
|
|
113
|
+
Any Strapi e-mail provider works. Example with Nodemailer in `config/plugins.js`:
|
|
223
114
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
Install the Strapi email plugin:
|
|
227
|
-
|
|
228
|
-
```bash
|
|
229
|
-
npm install @strapi/provider-email-nodemailer
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
Configure in `config/plugins.js`:
|
|
233
|
-
|
|
234
|
-
```javascript
|
|
115
|
+
```js
|
|
235
116
|
module.exports = ({ env }) => ({
|
|
236
117
|
email: {
|
|
237
118
|
config: {
|
|
238
119
|
provider: 'nodemailer',
|
|
239
120
|
providerOptions: {
|
|
240
|
-
host: env('SMTP_HOST'
|
|
241
|
-
port: env('SMTP_PORT', 587),
|
|
242
|
-
auth: {
|
|
243
|
-
user: env('SMTP_USERNAME'),
|
|
244
|
-
pass: env('SMTP_PASSWORD'),
|
|
245
|
-
},
|
|
121
|
+
host: env('SMTP_HOST'),
|
|
122
|
+
port: env.int('SMTP_PORT', 587),
|
|
123
|
+
auth: { user: env('SMTP_USERNAME'), pass: env('SMTP_PASSWORD') },
|
|
246
124
|
},
|
|
247
125
|
settings: {
|
|
248
|
-
defaultFrom:
|
|
249
|
-
defaultReplyTo:
|
|
126
|
+
defaultFrom: 'login@example.com',
|
|
127
|
+
defaultReplyTo: 'support@example.com',
|
|
250
128
|
},
|
|
251
129
|
},
|
|
252
130
|
},
|
|
253
131
|
});
|
|
254
132
|
```
|
|
255
133
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
You can use any Strapi-compatible email provider:
|
|
259
|
-
- SendGrid
|
|
260
|
-
- Mailgun
|
|
261
|
-
- Amazon SES
|
|
262
|
-
- Postmark
|
|
263
|
-
- Any SMTP service
|
|
264
|
-
|
|
265
|
-
See [Strapi Email Documentation](https://docs.strapi.io/dev-docs/plugins/email) for details.
|
|
266
|
-
|
|
267
|
-
### Email Designer 5 Integration (Optional)
|
|
268
|
-
|
|
269
|
-
This plugin is **fully compatible** with [Strapi Email Designer 5](https://www.npmjs.com/package/strapi-plugin-email-designer-5)!
|
|
134
|
+
### 4. Build, start and point links at your frontend
|
|
270
135
|
|
|
271
136
|
```bash
|
|
272
|
-
|
|
273
|
-
npm install strapi-plugin-email-designer-5
|
|
137
|
+
npm run build && npm run develop
|
|
274
138
|
```
|
|
275
139
|
|
|
276
|
-
|
|
277
|
-
- β
Create beautiful email templates in the visual designer
|
|
278
|
-
- β
Use template variables: `magicLink`, `token`, `user`, `expiresAt`
|
|
279
|
-
- β
Enable in Settings β Magic Link β Email Settings
|
|
140
|
+
In the admin panel, open **Settings > Magic Link > Settings > General**:
|
|
280
141
|
|
|
281
|
-
|
|
142
|
+
- **Frontend base URL**: `https://app.example.com`
|
|
143
|
+
- **Frontend login path**: `/auth/magic-link`
|
|
282
144
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
```bash
|
|
286
|
-
# Using npm
|
|
287
|
-
npm install strapi-plugin-magic-link-v5
|
|
145
|
+
Links now look like `https://app.example.com/auth/magic-link?loginToken=...`. URLs must use `https`; `http` is accepted only for `localhost`.
|
|
288
146
|
|
|
289
|
-
|
|
290
|
-
yarn add strapi-plugin-magic-link-v5
|
|
147
|
+
### 5. Send your first link
|
|
291
148
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
After installation, **restart your Strapi server**. The plugin will appear in your admin panel.
|
|
297
|
-
|
|
298
|
-
---
|
|
299
|
-
|
|
300
|
-
## π― Quick Start
|
|
301
|
-
|
|
302
|
-
### 1οΈβ£ First Time Setup - License Activation (Free)
|
|
303
|
-
|
|
304
|
-
**After installation, you'll see a license activation modal on first visit.**
|
|
305
|
-
|
|
306
|
-
Enter your details to activate the plugin (completely free):
|
|
307
|
-
|
|
308
|
-
```
|
|
309
|
-
Email Address: your-email@example.com
|
|
310
|
-
First Name: John
|
|
311
|
-
Last Name: Doe
|
|
149
|
+
```bash
|
|
150
|
+
curl -X POST http://localhost:1337/api/magic-link/send-link \
|
|
151
|
+
-H 'Content-Type: application/json' \
|
|
152
|
+
-d '{"email":"ada@example.com"}'
|
|
312
153
|
```
|
|
313
154
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
- β
Activate all features (no payment required)
|
|
317
|
-
- β
Connect to the license validation system
|
|
318
|
-
|
|
319
|
-
**Important:** This is a **free activation** - not a payment. It helps us track installations, provide support, and ensure security. You can also use an existing license key if you already have one.
|
|
320
|
-
|
|
321
|
-
### 2οΈβ£ Configure Settings
|
|
322
|
-
|
|
323
|
-
**Navigate to:** Settings β Magic Link β Settings
|
|
324
|
-
|
|
325
|
-
**Essential Configuration:**
|
|
326
|
-
|
|
327
|
-
```javascript
|
|
328
|
-
{
|
|
329
|
-
// Core Settings
|
|
330
|
-
"enabled": true,
|
|
331
|
-
"createUserIfNotExists": true, // Auto-create users on first login
|
|
332
|
-
"expire_period": 3600, // Token valid for 1 hour (3600s)
|
|
333
|
-
"token_length": 20, // Token security level (20-40 chars)
|
|
334
|
-
|
|
335
|
-
// Email Configuration
|
|
336
|
-
"from_email": "noreply@yourdomain.com",
|
|
337
|
-
"from_name": "Your App Name",
|
|
338
|
-
"object": "Your Magic Link Login",
|
|
339
|
-
"confirmationUrl": "https://yourdomain.com/auth/callback",
|
|
340
|
-
|
|
341
|
-
// Rate Limiting
|
|
342
|
-
"rate_limit_enabled": true,
|
|
343
|
-
"rate_limit_max_attempts": 5, // 5 requests per window
|
|
344
|
-
"rate_limit_window_minutes": 15 // 15 minute window
|
|
345
|
-
}
|
|
155
|
+
```json
|
|
156
|
+
{ "email": "ada@example.com", "username": null, "sent": true, "delivery": "email" }
|
|
346
157
|
```
|
|
347
158
|
|
|
348
|
-
|
|
349
|
-
- Go to **Settings β MFA & Login Modes**
|
|
350
|
-
- Select your preferred mode (Magic Link Only is default and free)
|
|
351
|
-
- Configure OTP/TOTP settings if using Premium/Advanced features
|
|
159
|
+
The answer is the same whether or not the account exists, so nobody can use it to find out who has an account. By default only existing users get a link; see [auto-registration](#auto-registration) to create accounts on first login.
|
|
352
160
|
|
|
353
|
-
|
|
161
|
+
The public routes need no role configuration. They are open by design (`auth: false`) and protected by rate limits and IP bans.
|
|
354
162
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
**Step 1: Request a magic link**
|
|
358
|
-
```javascript
|
|
359
|
-
const response = await fetch('/api/magic-link/send-link', {
|
|
360
|
-
method: 'POST',
|
|
361
|
-
headers: { 'Content-Type': 'application/json' },
|
|
362
|
-
body: JSON.stringify({
|
|
363
|
-
email: 'user@example.com',
|
|
364
|
-
context: { redirectTo: '/dashboard' }
|
|
365
|
-
})
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
const data = await response.json();
|
|
369
|
-
console.log('Magic link sent!', data);
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
**Step 2: Verify token on callback page**
|
|
373
|
-
```javascript
|
|
374
|
-
const urlParams = new URLSearchParams(window.location.search);
|
|
375
|
-
const loginToken = urlParams.get('loginToken');
|
|
376
|
-
|
|
377
|
-
if (loginToken) {
|
|
378
|
-
try {
|
|
379
|
-
const response = await fetch(`/api/magic-link/login?loginToken=${loginToken}`);
|
|
380
|
-
const { jwt, user } = await response.json();
|
|
381
|
-
|
|
382
|
-
// Store JWT for authenticated requests
|
|
383
|
-
localStorage.setItem('token', jwt);
|
|
384
|
-
localStorage.setItem('user', JSON.stringify(user));
|
|
385
|
-
|
|
386
|
-
// Redirect to dashboard
|
|
387
|
-
window.location.href = '/dashboard';
|
|
388
|
-
} catch (error) {
|
|
389
|
-
console.error('Login failed:', error);
|
|
390
|
-
alert('Invalid or expired magic link');
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
```
|
|
163
|
+
## Frontend integration
|
|
394
164
|
|
|
395
|
-
|
|
165
|
+
A complete login has three parts: a form that requests the link, a landing page that exchanges the token, and a place to keep the JWT.
|
|
396
166
|
|
|
397
|
-
|
|
167
|
+
### 1. Request the link
|
|
398
168
|
|
|
399
|
-
|
|
169
|
+
```js
|
|
170
|
+
// Login form, framework-agnostic
|
|
171
|
+
const API = 'https://api.example.com/api/magic-link';
|
|
400
172
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
async function verifyOTP(email, otpCode) {
|
|
404
|
-
const response = await fetch('/api/magic-link/verify-otp', {
|
|
173
|
+
export async function requestMagicLink(email) {
|
|
174
|
+
const response = await fetch(`${API}/send-link`, {
|
|
405
175
|
method: 'POST',
|
|
406
176
|
headers: { 'Content-Type': 'application/json' },
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
otp: otpCode
|
|
410
|
-
})
|
|
177
|
+
// Optional context comes back in the login response (see below).
|
|
178
|
+
body: JSON.stringify({ email, context: { redirectTo: '/dashboard' } }),
|
|
411
179
|
});
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
} else {
|
|
419
|
-
alert('Invalid OTP code. Please try again.');
|
|
180
|
+
if (response.status === 429) {
|
|
181
|
+
throw new Error('Too many requests. Please wait a moment and try again.');
|
|
182
|
+
}
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
const { error } = await response.json();
|
|
185
|
+
throw new Error(error?.message || 'The link could not be requested.');
|
|
420
186
|
}
|
|
187
|
+
// Always "sent": tell the user to check their inbox.
|
|
421
188
|
}
|
|
422
|
-
|
|
423
|
-
// Usage
|
|
424
|
-
const otpInput = document.getElementById('otp-input');
|
|
425
|
-
verifyOTP('user@example.com', otpInput.value);
|
|
426
189
|
```
|
|
427
190
|
|
|
428
|
-
|
|
191
|
+
### 2. Exchange the token on the landing page
|
|
192
|
+
|
|
193
|
+
The landing page receives `?loginToken=...`. Send the token to Strapi with **POST**, so it never shows up in access logs, caches or `Referer` headers, then react to the answer.
|
|
429
194
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
async function
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
const response = await fetch('/api/magic-link/totp/setup', {
|
|
195
|
+
```js
|
|
196
|
+
// /auth/magic-link page
|
|
197
|
+
async function post(path, body) {
|
|
198
|
+
const response = await fetch(`${API}${path}`, {
|
|
436
199
|
method: 'POST',
|
|
437
|
-
headers: {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
200
|
+
headers: { 'Content-Type': 'application/json' },
|
|
201
|
+
credentials: 'include', // only needed for the httpOnly refresh cookie
|
|
202
|
+
body: JSON.stringify(body),
|
|
441
203
|
});
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
// Display QR code to user
|
|
446
|
-
document.getElementById('qr-code').innerHTML = `<img src="${qrCode}" alt="Scan with authenticator app" />`;
|
|
447
|
-
|
|
448
|
-
// Show secret for manual entry
|
|
449
|
-
document.getElementById('secret').textContent = secret;
|
|
450
|
-
|
|
451
|
-
return secret;
|
|
204
|
+
const data = await response.json();
|
|
205
|
+
if (!response.ok) throw Object.assign(new Error(data.error?.message), { data });
|
|
206
|
+
return data;
|
|
452
207
|
}
|
|
453
|
-
```
|
|
454
208
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
const response = await fetch('/api/magic-link/totp/verify', {
|
|
461
|
-
method: 'POST',
|
|
462
|
-
headers: {
|
|
463
|
-
'Authorization': `Bearer ${token}`,
|
|
464
|
-
'Content-Type': 'application/json'
|
|
465
|
-
},
|
|
466
|
-
body: JSON.stringify({
|
|
467
|
-
token: totpCode
|
|
468
|
-
})
|
|
469
|
-
});
|
|
209
|
+
export async function completeMagicLink() {
|
|
210
|
+
const loginToken = new URLSearchParams(location.search).get('loginToken');
|
|
211
|
+
history.replaceState(null, '', location.pathname); // drop the token from the address bar
|
|
212
|
+
|
|
213
|
+
const result = await post('/login', { loginToken });
|
|
470
214
|
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
alert('Invalid code. Please try again.');
|
|
476
|
-
return false;
|
|
215
|
+
if (result.requiresOTP) {
|
|
216
|
+
// An e-mailed code is required. Ask the user for it, then:
|
|
217
|
+
const code = await askUser('Enter the code from your e-mail');
|
|
218
|
+
return post('/otp/verify', { email: result.email, code, magicLinkToken: result.loginToken });
|
|
477
219
|
}
|
|
478
|
-
}
|
|
479
|
-
```
|
|
480
220
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
headers: { 'Content-Type': 'application/json' },
|
|
487
|
-
body: JSON.stringify({
|
|
488
|
-
email: email,
|
|
489
|
-
token: totpCode
|
|
490
|
-
})
|
|
491
|
-
});
|
|
221
|
+
if (result.requiresTOTP) {
|
|
222
|
+
// The user has an authenticator. A backup code works too: { loginToken, backupCode }
|
|
223
|
+
const totpCode = await askUser('Enter the code from your authenticator app');
|
|
224
|
+
return post('/verify-mfa-totp', { loginToken: result.loginToken, totpCode });
|
|
225
|
+
}
|
|
492
226
|
|
|
493
|
-
if (
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
227
|
+
if (result.requiresTOTPSetup) {
|
|
228
|
+
// MFA is required and the user has no authenticator yet: enrol now.
|
|
229
|
+
const { data } = await post('/totp/enroll', { loginToken: result.loginToken });
|
|
230
|
+
showQrCode(data.qrCode); // data URL; data.secret for manual entry
|
|
231
|
+
const totpCode = await askUser('Scan the QR code and enter the code');
|
|
232
|
+
const session = await post('/totp/enroll/confirm', { loginToken: result.loginToken, totpCode });
|
|
233
|
+
showBackupCodesOnce(session.backupCodes);
|
|
234
|
+
return session;
|
|
500
235
|
}
|
|
236
|
+
|
|
237
|
+
return result; // { jwt, user, context, expires_at, refreshToken? }
|
|
501
238
|
}
|
|
502
239
|
```
|
|
503
240
|
|
|
504
|
-
|
|
241
|
+
`askUser`, `showQrCode` and `showBackupCodesOnce` stand for your own UI.
|
|
505
242
|
|
|
506
|
-
|
|
507
|
-
async function checkLicenseTier() {
|
|
508
|
-
const adminToken = localStorage.getItem('adminToken');
|
|
509
|
-
|
|
510
|
-
const response = await fetch('/magic-link/license/status', {
|
|
511
|
-
headers: {
|
|
512
|
-
'Authorization': `Bearer ${adminToken}`
|
|
513
|
-
}
|
|
514
|
-
});
|
|
243
|
+
### 3. Store the JWT and call your API
|
|
515
244
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
// data.features.premium - Email OTP available
|
|
519
|
-
// data.features.advanced - TOTP MFA available
|
|
520
|
-
// data.features.enterprise - All features
|
|
521
|
-
|
|
522
|
-
return {
|
|
523
|
-
isPremium: data.features?.premium || false,
|
|
524
|
-
isAdvanced: data.features?.advanced || false,
|
|
525
|
-
isEnterprise: data.features?.enterprise || false
|
|
526
|
-
};
|
|
527
|
-
}
|
|
245
|
+
The `jwt` is a normal users-permissions JWT. Send it as `Authorization: Bearer <jwt>`.
|
|
528
246
|
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
247
|
+
```js
|
|
248
|
+
const session = await completeMagicLink();
|
|
249
|
+
sessionStorage.setItem('jwt', session.jwt); // or keep it in memory
|
|
250
|
+
location.assign(session.context?.redirectTo || '/');
|
|
251
|
+
|
|
252
|
+
// Later
|
|
253
|
+
const me = await fetch('https://api.example.com/api/users/me', {
|
|
254
|
+
headers: { Authorization: `Bearer ${sessionStorage.getItem('jwt')}` },
|
|
255
|
+
}).then((r) => r.json());
|
|
534
256
|
```
|
|
535
257
|
|
|
536
|
-
|
|
258
|
+
Anything in web storage can be read by a script injected into your page. If that matters for you, keep the JWT in memory and use [refresh mode](#refresh-mode-users-permissions-sessions) with the refresh token in an httpOnly cookie.
|
|
259
|
+
|
|
260
|
+
`GET /api/magic-link/login?loginToken=...` still works. You can point `confirmationUrl` straight at it, but the token then ends up in server logs, so prefer the POST exchange.
|
|
537
261
|
|
|
538
|
-
|
|
262
|
+
### Where the link points
|
|
539
263
|
|
|
540
|
-
|
|
264
|
+
The link builder uses the first match:
|
|
541
265
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
| `POST` | `/api/magic-link/send-link` | Generate and send magic link to email |
|
|
546
|
-
| `GET` | `/api/magic-link/login?loginToken=xxx` | Authenticate user with token |
|
|
547
|
-
| `POST` | `/api/magic-link/verify-otp` | Verify Email OTP code |
|
|
548
|
-
| `POST` | `/api/magic-link/totp/login` | Login with Email + TOTP code |
|
|
266
|
+
1. `frontend_url` + `frontend_login_path` (recommended)
|
|
267
|
+
2. `confirmationUrl`
|
|
268
|
+
3. `<server.url><api prefix>/magic-link/login` (the API route itself)
|
|
549
269
|
|
|
550
|
-
|
|
551
|
-
| Method | Endpoint | Description |
|
|
552
|
-
|--------|----------|-------------|
|
|
553
|
-
| `POST` | `/api/magic-link/totp/setup` | Generate TOTP secret & QR code |
|
|
554
|
-
| `POST` | `/api/magic-link/totp/verify` | Verify and enable TOTP |
|
|
555
|
-
| `POST` | `/api/magic-link/totp/disable` | Disable TOTP for user |
|
|
556
|
-
| `GET` | `/api/magic-link/totp/status` | Check if TOTP is configured |
|
|
270
|
+
Existing query strings are kept and `loginToken` is appended.
|
|
557
271
|
|
|
558
|
-
###
|
|
272
|
+
### Passing context through the link
|
|
559
273
|
|
|
560
|
-
|
|
561
|
-
| Method | Endpoint | Description |
|
|
562
|
-
|--------|----------|-------------|
|
|
563
|
-
| `GET` | `/magic-link/tokens` | List all magic link tokens |
|
|
564
|
-
| `POST` | `/magic-link/tokens` | Create a new token |
|
|
565
|
-
| `DELETE` | `/magic-link/tokens/:id` | Permanently delete a token |
|
|
566
|
-
| `POST` | `/magic-link/tokens/:id/block` | Block a token |
|
|
567
|
-
| `POST` | `/magic-link/tokens/:id/activate` | Activate blocked token |
|
|
568
|
-
| `POST` | `/magic-link/tokens/:id/extend` | Extend token validity |
|
|
274
|
+
`send-link` accepts a small `context` object, for example `{ "redirectTo": "/billing" }`. It comes back in the login response. Rules:
|
|
569
275
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
| `GET` | `/magic-link/jwt-sessions` | List active JWT sessions |
|
|
574
|
-
| `POST` | `/magic-link/revoke-jwt` | Revoke a JWT session |
|
|
575
|
-
| `POST` | `/magic-link/ban-ip` | Ban an IP address |
|
|
576
|
-
| `DELETE` | `/magic-link/ban-ip/:ip` | Unban an IP address |
|
|
577
|
-
| `GET` | `/magic-link/banned-ips` | List all banned IPs |
|
|
578
|
-
| `GET` | `/magic-link/rate-limit/stats` | Get rate limit statistics |
|
|
579
|
-
| `POST` | `/magic-link/rate-limit/cleanup` | Cleanup expired entries |
|
|
276
|
+
- at most 20 keys;
|
|
277
|
+
- the `context_whitelist` / `context_blacklist` settings apply;
|
|
278
|
+
- reserved keys that carry authentication state (`otpVerified`, `mfaVerified`, `userId`, `exp`, ...) are rejected with 400.
|
|
580
279
|
|
|
581
|
-
|
|
582
|
-
| Method | Endpoint | Description |
|
|
583
|
-
|--------|----------|-------------|
|
|
584
|
-
| `GET` | `/magic-link/otp-codes` | List all OTP codes |
|
|
585
|
-
| `POST` | `/magic-link/otp-codes/cleanup` | Cleanup expired OTP codes |
|
|
280
|
+
## Second factors: e-mail OTP, TOTP and backup codes
|
|
586
281
|
|
|
587
|
-
|
|
588
|
-
| Method | Endpoint | Description |
|
|
589
|
-
|--------|----------|-------------|
|
|
590
|
-
| `GET` | `/magic-link/license/status` | Get current license status |
|
|
591
|
-
| `POST` | `/magic-link/license/auto-create` | Create license with admin data |
|
|
592
|
-
| `POST` | `/magic-link/license/activate` | Activate license with key |
|
|
282
|
+
Choose the mode in **Settings > Magic Link > Settings > Sign-in modes and MFA**.
|
|
593
283
|
|
|
594
|
-
|
|
284
|
+
| Mode | Setting | What the user does after clicking the link |
|
|
285
|
+
| --------------------- | ---------------------- | ----------------------------------------------------------- |
|
|
286
|
+
| Link only | defaults | Nothing, the link logs them in |
|
|
287
|
+
| Link + e-mail OTP | `otp_enabled: true` | Enters a 6-digit code sent in a second e-mail |
|
|
288
|
+
| Link + TOTP, optional | `mfa_mode: "optional"` | Users with an authenticator enter a code; others log in |
|
|
289
|
+
| Link + TOTP, required | `mfa_mode: "required"` | Everyone enters a code; users without one enrol on the spot |
|
|
595
290
|
|
|
596
|
-
|
|
291
|
+
**Switching to `required` while nobody is enrolled.** The settings API refuses `mfa_mode: "required"` (or the legacy `mfa_require_totp: true`) with `400` and `error.details.key: 'settings.mfa.noEnrolledUsers'` while no end user has an active authenticator, because a frontend without the enrolment step would lock everyone out. Start with `optional` so users can enrol, or, if your landing page handles `requiresTOTPSetup`, send `acknowledgeNoEnrolledUsers: true` with the update (`{ "settings": { "mfa_mode": "required" }, "acknowledgeNoEnrolledUsers": true }`). The flag is never stored. The admin panel blocks the option in the same situation.
|
|
292
|
+
| TOTP only | `totp_as_primary_auth: true` | Calls `POST /login-totp { email, totpCode }`, no e-mail |
|
|
597
293
|
|
|
598
|
-
|
|
294
|
+
A user with an active authenticator always gets the TOTP step, even when e-mail OTP is also enabled.
|
|
599
295
|
|
|
600
|
-
###
|
|
296
|
+
### End-user TOTP enrolment
|
|
601
297
|
|
|
602
|
-
|
|
298
|
+
There are two ways for a user to add an authenticator.
|
|
603
299
|
|
|
604
|
-
**
|
|
300
|
+
**During sign-in (required MFA).** When `mfa_mode` is `required` and the user has no authenticator, `/login` answers `requiresTOTPSetup`. The landing page calls `POST /totp/enroll { loginToken }`, shows the QR code, and finishes with `POST /totp/enroll/confirm { loginToken, totpCode }`. That response is the session plus ten backup codes (see the [frontend example](#2-exchange-the-token-on-the-landing-page)). The pending setup is valid for 15 minutes. The link click already proved the mailbox, so no extra e-mail code is needed.
|
|
605
301
|
|
|
606
|
-
|
|
302
|
+
**From the account (self-service).** These routes use the users-permissions JWT. Grant the **Magic-link > totp** actions (`status`, `setup`, `confirm`, `disable`, `backupCodes`) to the **Authenticated** role in _Settings > Users & Permissions > Roles_.
|
|
607
303
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
| `POST /api/passwordless/send-link` | `auth.sendLink` | Send magic link email |
|
|
611
|
-
| `GET /api/passwordless/login?loginToken=xxx` | `auth.login` | Authenticate with token |
|
|
304
|
+
```js
|
|
305
|
+
const auth = { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' };
|
|
612
306
|
|
|
613
|
-
|
|
307
|
+
// 1. Start: returns the QR code and e-mails a confirmation code.
|
|
308
|
+
// With an active authenticator, also send { totpCode } from the current app.
|
|
309
|
+
const { data } = await fetch(`${API}/totp/setup`, {
|
|
310
|
+
method: 'POST',
|
|
311
|
+
headers: auth,
|
|
312
|
+
body: '{}',
|
|
313
|
+
}).then((r) => r.json());
|
|
314
|
+
// data: { secret, qrCode, otpauthUrl, algorithm, digits, period, expiresAt, emailCodeSent: true }
|
|
614
315
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const response = await fetch('/api/passwordless/send-link', {
|
|
316
|
+
// 2. Confirm with a code from the app AND the e-mailed code.
|
|
317
|
+
await fetch(`${API}/totp/confirm`, {
|
|
618
318
|
method: 'POST',
|
|
619
|
-
headers:
|
|
620
|
-
body: JSON.stringify({
|
|
621
|
-
email: 'user@example.com',
|
|
622
|
-
username: 'John Doe', // Optional: username for new users
|
|
623
|
-
context: { redirectTo: '/dashboard' }
|
|
624
|
-
})
|
|
319
|
+
headers: auth,
|
|
320
|
+
body: JSON.stringify({ totpCode, emailCode }),
|
|
625
321
|
});
|
|
322
|
+
// { data: { enabled: true, backupCodes: [10 codes, shown once] } }
|
|
626
323
|
|
|
627
|
-
//
|
|
628
|
-
|
|
324
|
+
// Status, new backup codes (needs a current code), remove (TOTP or backup code)
|
|
325
|
+
await fetch(`${API}/totp/status`, { headers: auth });
|
|
326
|
+
// { data: { configured, enabled, pendingSetup, lastUsed, backupCodesRemaining,
|
|
327
|
+
// algorithm, digits, period, mfaMode } }
|
|
328
|
+
await fetch(`${API}/totp/backup-codes`, {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: auth,
|
|
331
|
+
body: JSON.stringify({ totpCode }),
|
|
332
|
+
});
|
|
333
|
+
await fetch(`${API}/totp/disable`, {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: auth,
|
|
336
|
+
body: JSON.stringify({ totpCode }),
|
|
337
|
+
});
|
|
629
338
|
```
|
|
630
339
|
|
|
631
|
-
|
|
632
|
-
1. Install `strapi-plugin-magic-link-v5`
|
|
633
|
-
2. Remove `strapi-plugin-passwordless` from `package.json`
|
|
634
|
-
3. Enable **Passwordless Compatibility** in Settings
|
|
635
|
-
4. Restart Strapi - done! β
|
|
636
|
-
|
|
637
|
-
**Gradual Migration:** You can later switch your frontend to use `/api/magic-link/*` endpoints and disable compatibility mode.
|
|
340
|
+
A stolen JWT alone cannot add, replace or remove an authenticator:
|
|
638
341
|
|
|
639
|
-
|
|
342
|
+
- Setup needs the e-mailed code.
|
|
343
|
+
- Re-setup and new backup codes need a current TOTP code.
|
|
344
|
+
- Removal needs a TOTP code or a backup code.
|
|
640
345
|
|
|
641
|
-
|
|
346
|
+
### Backup codes
|
|
642
347
|
|
|
643
|
-
|
|
348
|
+
- Ten 16-character codes (`XXXX-XXXX-XXXX-XXXX`), shown once.
|
|
349
|
+
- Stored as keyed hashes bound to their owner.
|
|
350
|
+
- Each code works once wherever a TOTP code is accepted after a magic link (`backupCode` instead of `totpCode`).
|
|
351
|
+
- The response reports `backupCodesRemaining`.
|
|
644
352
|
|
|
645
|
-
|
|
646
|
-
# Install Email Designer 5
|
|
647
|
-
npm install strapi-plugin-email-designer-5
|
|
648
|
-
```
|
|
353
|
+
Algorithm, digits and period are stored with each enrolment, so changing the TOTP settings only affects new enrolments. Each accepted time step is recorded, so a code cannot be replayed.
|
|
649
354
|
|
|
650
|
-
|
|
651
|
-
| Variable | Description |
|
|
652
|
-
|----------|-------------|
|
|
653
|
-
| `{{magicLink}}` | Complete magic link URL with token |
|
|
654
|
-
| `{{token}}` | Raw token value |
|
|
655
|
-
| `{{user.email}}` | User's email address |
|
|
656
|
-
| `{{user.username}}` | User's username |
|
|
657
|
-
| `{{expiresAt}}` | Token expiration time |
|
|
355
|
+
## Refresh mode (users-permissions sessions)
|
|
658
356
|
|
|
659
|
-
|
|
660
|
-
1. Create a template in Email Designer
|
|
661
|
-
2. Go to Settings β Magic Link β Email Settings
|
|
662
|
-
3. Enable "Use Email Designer Template"
|
|
663
|
-
4. Select your template from the dropdown
|
|
357
|
+
With `jwtManagement: 'refresh'` in the users-permissions config, Magic Link logins behave like `/api/auth/local`: the plugin creates the session through the users-permissions session manager.
|
|
664
358
|
|
|
665
|
-
|
|
359
|
+
**Versions.** The users-permissions session manager and `jwtManagement` exist since Strapi 5.24.0. Strapi's own `POST /api/auth/refresh` reads the refresh token from the httpOnly cookie only from 5.33.3 on (checked in the `@strapi/plugin-users-permissions` 5.33.2 and 5.33.3 sources); on 5.24 to 5.33.2 send the refresh token in the request body instead of using the cookie.
|
|
666
360
|
|
|
667
|
-
|
|
361
|
+
```js
|
|
362
|
+
// config/plugins.js
|
|
363
|
+
module.exports = {
|
|
364
|
+
'users-permissions': {
|
|
365
|
+
config: {
|
|
366
|
+
jwtManagement: 'refresh',
|
|
367
|
+
sessions: { accessTokenLifespan: 600, maxRefreshTokenLifespan: 1209600, httpOnly: false },
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
```
|
|
668
372
|
|
|
669
|
-
|
|
373
|
+
- The response contains a short-lived `jwt` and a `refreshToken`. With `sessions.httpOnly: true`, or the request header `x-strapi-refresh-cookie: httpOnly`, the refresh token is set as an httpOnly cookie (default name `strapi_up_refresh`) instead and left out of the body.
|
|
374
|
+
- Renew with the native `POST /api/auth/refresh`, sending `{ refreshToken }` or the cookie. Log out with `POST /api/auth/logout`.
|
|
375
|
+
- You may send `deviceId` with `/login`, `/verify-mfa-totp`, `/login-totp` or `/totp/enroll/confirm`, as with `/api/auth/local`. Without it, every Magic Link login gets its own device id.
|
|
376
|
+
- Revoking a session in **Magic Link > JWT Sessions** ends the whole users-permissions refresh family: the first refresh token and every token rotated from it. (`POST /api/auth/refresh` creates a new session id with the same device id; the plugin revokes by user and device id.) A client that reuses one `deviceId` for several logins gets all of them ended, like the users-permissions device logout.
|
|
377
|
+
- Access tokens minted from a revoked family are rejected as well. users-permissions itself accepts an access token until it expires. While a user has a revoked family (until the family's maximum lifetime plus one access-token lifespan), the plugin checks the users-permissions session of each of that user's access tokens (`isSessionActive`, one database lookup per request for that user only). It answers `401` when the session is gone and `503` when the state cannot be read.
|
|
378
|
+
- Session records written before the device id was stored can only revoke the first refresh token.
|
|
670
379
|
|
|
671
|
-
|
|
380
|
+
In the default `legacy-support` mode the JWT lifetime comes from the plugin setting `jwt_token_expires_in` (for example `15m`, `12h`, `30d`).
|
|
672
381
|
|
|
673
|
-
|
|
382
|
+
## Public API
|
|
674
383
|
|
|
675
|
-
|
|
384
|
+
All routes are under `/api/magic-link` (or your `api.rest.prefix`). Every answer from these route handlers sends `Cache-Control: no-store` and `Referrer-Policy: no-referrer` (a request refused by the IP-ban check is answered before the handler runs). Unknown body fields are rejected with 400.
|
|
676
385
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
"totp_period": 30, // Time period (seconds)
|
|
695
|
-
"totp_algorithm": "sha1" // "sha1" | "sha256" | "sha512"
|
|
696
|
-
}
|
|
697
|
-
```
|
|
386
|
+
| Method | Path | Auth | Body | Success response |
|
|
387
|
+
| ------ | ---------------------- | ----------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
388
|
+
| `POST` | `/send-link` | none | `{ email, username?, context?, delivery? }` | `{ email, username, sent: true, delivery: "email" }` |
|
|
389
|
+
| `POST` | `/login` | none | `{ loginToken, deviceId? }` | session, or a challenge (`requiresOTP`, `requiresTOTP`, `requiresTOTPSetup`) |
|
|
390
|
+
| `GET` | `/login?loginToken=` | none | (query) | same as `POST /login` |
|
|
391
|
+
| `POST` | `/otp/send` | none | `{ email, magicLinkToken }` | `{ success, message, expiresIn, type: "email" }` |
|
|
392
|
+
| `POST` | `/otp/resend` | none | `{ email, magicLinkToken }` | `{ success, message, expiresIn }`, after `otp_resend_cooldown` |
|
|
393
|
+
| `POST` | `/otp/verify` | none | `{ email, code, magicLinkToken }` | session with `otpVerified: true` |
|
|
394
|
+
| `POST` | `/verify-mfa-totp` | none | `{ loginToken, totpCode }` or `{ loginToken, backupCode }` | session with `mfaVerified: true` |
|
|
395
|
+
| `POST` | `/login-totp` | none | `{ email, totpCode }` | session with `loginMethod: "totp"` (TOTP-only mode) |
|
|
396
|
+
| `POST` | `/totp/enroll` | none (loginToken) | `{ loginToken }` | `{ data: { secret, qrCode, otpauthUrl, algorithm, digits, period, expiresAt } }` |
|
|
397
|
+
| `POST` | `/totp/enroll/confirm` | none (loginToken) | `{ loginToken, totpCode }` | session plus `backupCodes` |
|
|
398
|
+
| `GET` | `/totp/status` | users-permissions | | `{ data: { configured, enabled, ..., mfaMode } }` |
|
|
399
|
+
| `POST` | `/totp/setup` | users-permissions | `{}` or `{ totpCode }` when already enrolled | `{ data: { secret, qrCode, ..., emailCodeSent: true } }` |
|
|
400
|
+
| `POST` | `/totp/confirm` | users-permissions | `{ totpCode, emailCode }` | `{ data: { enabled: true, backupCodes } }` |
|
|
401
|
+
| `POST` | `/totp/disable` | users-permissions | `{ totpCode }` or `{ backupCode }` | `{ data: { enabled: false } }` |
|
|
402
|
+
| `POST` | `/totp/backup-codes` | users-permissions | `{ totpCode }` | `{ data: { backupCodes } }` |
|
|
698
403
|
|
|
699
|
-
|
|
404
|
+
`magicLinkToken` is the `loginToken` from the `/login` challenge. It is required while `otp_strict_binding` is on (the default).
|
|
700
405
|
|
|
701
|
-
|
|
702
|
-
{
|
|
703
|
-
"enabled": true, // Enable/disable plugin
|
|
704
|
-
"createUserIfNotExists": true, // Auto-create users on first login
|
|
705
|
-
"expire_period": 3600, // Token expiration (seconds)
|
|
706
|
-
"token_length": 20, // Token security level (20-40)
|
|
707
|
-
"stays_valid": false, // Token reusable after first use
|
|
708
|
-
"max_login_attempts": 5, // Limit failed login attempts
|
|
709
|
-
"callback_url": "https://yourdomain.com/auth/callback"
|
|
710
|
-
}
|
|
711
|
-
```
|
|
406
|
+
### Session response
|
|
712
407
|
|
|
713
|
-
|
|
408
|
+
Every successful login answers like this:
|
|
714
409
|
|
|
715
|
-
```
|
|
410
|
+
```json
|
|
716
411
|
{
|
|
717
|
-
"
|
|
718
|
-
"
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
412
|
+
"jwt": "eyJhbGciOi...",
|
|
413
|
+
"user": {
|
|
414
|
+
"id": 7,
|
|
415
|
+
"documentId": "abc123",
|
|
416
|
+
"username": "ada",
|
|
417
|
+
"email": "ada@example.com",
|
|
418
|
+
"confirmed": true,
|
|
419
|
+
"blocked": false
|
|
420
|
+
},
|
|
421
|
+
"context": { "redirectTo": "/billing" },
|
|
422
|
+
"expires_at": "2026-12-07T10:00:00.000Z",
|
|
423
|
+
"refreshToken": "only in refresh mode, unless the cookie is used"
|
|
723
424
|
}
|
|
724
425
|
```
|
|
725
426
|
|
|
726
|
-
|
|
727
|
-
-
|
|
728
|
-
-
|
|
729
|
-
- `<%= USER %>` - User email/username
|
|
730
|
-
- `<%= EXPIRES_AT %>` - Token expiration time
|
|
427
|
+
- The user object passes Strapi's content-API sanitizer, so private fields are never returned.
|
|
428
|
+
- Second-factor logins add `otpVerified: true`, `mfaVerified: true` (plus `backupCodesRemaining` after a backup code) or `loginMethod: "totp"`.
|
|
429
|
+
- With `use_jwt_token: false` no token is minted: the body has `user`, `context` and `jwtIssued: false`.
|
|
731
430
|
|
|
732
|
-
###
|
|
431
|
+
### Challenge responses from `/login`
|
|
733
432
|
|
|
734
|
-
```
|
|
735
|
-
{
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
"store_login_info": true // Track IP and user agent
|
|
739
|
-
}
|
|
433
|
+
```json
|
|
434
|
+
{ "requiresOTP": true, "message": "OTP verification required", "email": "ada@example.com", "loginToken": "...", "expiresIn": 300 }
|
|
435
|
+
{ "requiresTOTP": true, "message": "TOTP verification required for MFA", "email": "ada@example.com", "loginToken": "..." }
|
|
436
|
+
{ "requiresTOTPSetup": true, "message": "Set up an authenticator app to continue", "email": "ada@example.com", "loginToken": "...", "enrollEndpoint": "/api/magic-link/totp/enroll" }
|
|
740
437
|
```
|
|
741
438
|
|
|
742
|
-
|
|
439
|
+
Opening the same link again while a challenge is pending returns the same challenge. It does not send a new e-mail while the code is still valid.
|
|
743
440
|
|
|
744
|
-
|
|
441
|
+
## Errors
|
|
442
|
+
|
|
443
|
+
Most errors use the Strapi error envelope with a stable `key`:
|
|
444
|
+
|
|
445
|
+
```json
|
|
745
446
|
{
|
|
746
|
-
"
|
|
747
|
-
"
|
|
748
|
-
|
|
447
|
+
"data": null,
|
|
448
|
+
"error": {
|
|
449
|
+
"status": 400,
|
|
450
|
+
"name": "ValidationError",
|
|
451
|
+
"message": "Invalid or expired token",
|
|
452
|
+
"key": "token.invalid",
|
|
453
|
+
"details": { "key": "token.invalid" }
|
|
454
|
+
}
|
|
749
455
|
}
|
|
750
456
|
```
|
|
751
457
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
The
|
|
770
|
-
|
|
771
|
-
|
|
458
|
+
`message` is translated (`en`, `de`, `fr`, `es`, `pt`) from `?locale=` or `Accept-Language`. Build your UI on `key`, not on the text.
|
|
459
|
+
|
|
460
|
+
| Status | `key` | When |
|
|
461
|
+
| ------ | -------------------------- | -------------------------------------------------------------------------------- |
|
|
462
|
+
| 400 | `token.invalid` | The link is missing, expired, used, blocked, or its account no longer exists |
|
|
463
|
+
| 400 | `challenge.invalid` | The `loginToken` has no matching pending TOTP or enrolment step |
|
|
464
|
+
| 400 | `mfa.invalidCode` | Wrong TOTP or backup code |
|
|
465
|
+
| 400 | `credentials.invalid` | `/login-totp`: unknown account, no authenticator or wrong code (one answer) |
|
|
466
|
+
| 400 | `totp.noPendingSetup` | Confirm without a started setup, or the setup expired |
|
|
467
|
+
| 400 | `totp.invalidEmailCode` | `/totp/confirm`: the e-mailed code is wrong or expired |
|
|
468
|
+
| 400 | `totp.notEnrolled` | `/totp/disable` or `/totp/backup-codes` without an authenticator |
|
|
469
|
+
| 401 | `auth.required` | Self-service routes without a users-permissions JWT |
|
|
470
|
+
| 403 | `blocked.user` | The account is blocked |
|
|
471
|
+
| 403 | `totp.currentCodeRequired` | `/totp/setup` while an authenticator is active, without a current `totpCode` |
|
|
472
|
+
| 403 | `totp.loginDisabled` | `/login-totp` while `totp_as_primary_auth` is off |
|
|
473
|
+
| 409 | `totp.alreadyEnrolled` | `/totp/enroll` for a user who already has an authenticator |
|
|
474
|
+
| 429 | `rate.limit.exceeded` | Rate limit hit; `details.retryAfter` and the `Retry-After` header give seconds |
|
|
475
|
+
| 503 | `otp.deliveryFailed` | The OTP e-mail could not be sent (`details.resendEndpoint` points at the resend) |
|
|
476
|
+
|
|
477
|
+
Some answers do not carry a `key`. Handle them by status:
|
|
478
|
+
|
|
479
|
+
- **Body validation** (unknown field, bad e-mail, removed `phoneNumber`/`phone`/`via`/non-e-mail `delivery`): `400 ValidationError` with a message such as `Invalid request body at 'email': ...`.
|
|
480
|
+
- **IP bans**: `403 PolicyError` with `details.code: "IP_ADDRESS_BLOCKED"`, or `details.code: "IP_BAN_CHECK_UNAVAILABLE"` when the ban list cannot be read.
|
|
481
|
+
- **`/otp/send`, `/otp/resend`, `/otp/verify`**: plain Strapi errors.
|
|
482
|
+
- A wrong or expired code answers `400` "Invalid or expired verification code".
|
|
483
|
+
- Limits answer `429` with a "try again in N seconds" message and a `Retry-After` header.
|
|
484
|
+
- A failed OTP e-mail answers `503 { "success": false, "error": { "code": "OTP_EMAIL_FAILED", ... } }`.
|
|
485
|
+
- A `magicLinkToken` that does not match gets the same answer as a valid one (`{ "success": true, "message": "If the request is valid, a verification code will be sent." }`), so the route cannot be used to test tokens.
|
|
486
|
+
- **Revoked JWTs** on any Content API route: `401 UnauthorizedError` "This token has been revoked"; `503` when the revocation state cannot be read.
|
|
487
|
+
|
|
488
|
+
## Admin API
|
|
489
|
+
|
|
490
|
+
Admin routes live under `/magic-link` on the Strapi server (not under `/api`). They need an admin JWT (`Authorization: Bearer <admin token>`) and the listed permission. The admin pages use them; you rarely need them directly.
|
|
491
|
+
|
|
492
|
+
| Method | Path | Permission | Purpose |
|
|
493
|
+
| -------- | ------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------- |
|
|
494
|
+
| `GET` | `/magic-link/tokens` | `read` | Newest 1000 tokens, or `?page=&pageSize=` (max 1000) |
|
|
495
|
+
| `POST` | `/magic-link/tokens` | `manage` | Create a link: `{ email, ttl?, send_email?, context? }` (ttl in hours, 1-720) |
|
|
496
|
+
| `POST` | `/magic-link/tokens/:id/block` | `manage` | Deactivate a link |
|
|
497
|
+
| `POST` | `/magic-link/tokens/:id/activate` | `manage` | Re-activate (refused for used single-use or expired links) |
|
|
498
|
+
| `POST` | `/magic-link/tokens/:id/extend` | `manage` | `{ days }`, 1-365 (default 7) |
|
|
499
|
+
| `DELETE` | `/magic-link/tokens/:id` | `manage` | Delete a link |
|
|
500
|
+
| `GET` | `/magic-link/jwt-sessions` | `read` | Sessions issued by Magic Link |
|
|
501
|
+
| `POST` | `/magic-link/revoke-jwt` | `manage` | `{ sessionId }` or `{ token }` |
|
|
502
|
+
| `POST` | `/magic-link/unrevoke-jwt` | `manage` | `{ sessionId }` (not for refresh-mode sessions) |
|
|
503
|
+
| `POST` | `/magic-link/cleanup-sessions` | `manage` | Remove expired session records |
|
|
504
|
+
| `GET` | `/magic-link/banned-ips` | `read` | `{ ips: [...] }` |
|
|
505
|
+
| `POST` | `/magic-link/ban-ip` | `manage` | `{ data: { ip } }` |
|
|
506
|
+
| `POST` | `/magic-link/unban-ip` | `manage` | `{ data: { ip } }` |
|
|
507
|
+
| `GET` | `/magic-link/otp/codes` | `read` | `?page=&pageSize=&email=&type=&status=`; codes, stats and pagination (no code hashes) |
|
|
508
|
+
| `DELETE` | `/magic-link/otp/codes/:id` | `manage` | Delete one code |
|
|
509
|
+
| `POST` | `/magic-link/otp/cleanup` | `manage` | Remove expired codes |
|
|
510
|
+
| `GET` | `/magic-link/settings` | `settings.read` | Read settings |
|
|
511
|
+
| `PUT` | `/magic-link/settings` | `settings.update` | Save settings (only known keys, validated); security settings also need `settings.security` |
|
|
512
|
+
| `POST` | `/magic-link/reset-data` | `settings.update` | Restore defaults: `{ confirm: 'RESET', resetSecurity? }`; also needs `settings.security` |
|
|
513
|
+
| `GET` | `/magic-link/rate-limit/stats` | `settings.read` | Limiter statistics |
|
|
514
|
+
| `POST` | `/magic-link/rate-limit/cleanup` | `settings.update` | Drop expired limiter entries |
|
|
515
|
+
| `POST` | `/magic-link/rate-limit/reset` | `settings.update` | Reset all limits |
|
|
516
|
+
| `GET` | `/magic-link/totp/enrollment-summary` | `settings.read` | `{ data: { enrolled, enrollmentDuringLogin } }` |
|
|
517
|
+
| `GET` | `/magic-link/license/status` | `settings.read` | License state; full key only with `settings.update` |
|
|
518
|
+
| `POST` | `/magic-link/license/store-key` | `settings.update` | Save a license key |
|
|
519
|
+
| `GET` | `/magic-link/otp/totp/status` | `read` | The calling admin's own authenticator |
|
|
520
|
+
| `POST` | `/magic-link/otp/totp/setup` | `read` | Start setup (`{ token }` when one is active) |
|
|
521
|
+
| `POST` | `/magic-link/otp/totp/verify` | `read` | Confirm setup, returns backup codes |
|
|
522
|
+
| `POST` | `/magic-link/otp/totp/disable` | `read` | `{ token }` |
|
|
523
|
+
| `POST` | `/magic-link/otp/totp/backup-codes` | `read` | `{ token }` |
|
|
524
|
+
|
|
525
|
+
A missing permission answers `403`; a missing or expired admin token answers `401`.
|
|
526
|
+
|
|
527
|
+
`reset-data` needs `settings.security` as well as `settings.update`, because it also deletes every login token, the JWT session list and all IP bans and resets link targets, templates, sender and code rules. The MFA settings are kept unless `resetSecurity: true` is sent. Active JWT revocations are kept. Without the session list, refresh-mode sessions can no longer be revoked from the admin panel.
|
|
528
|
+
|
|
529
|
+
#### Example: create and e-mail a link
|
|
772
530
|
|
|
773
531
|
```bash
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
# 16+ random bytes β pepper mixed into OTP code hashes to defeat
|
|
779
|
-
# rainbow-table attacks on stored 6-digit codes if the DB is ever leaked.
|
|
780
|
-
MAGIC_LINK_OTP_PEPPER=<hex from: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))">
|
|
532
|
+
curl -X POST http://localhost:1337/magic-link/tokens \
|
|
533
|
+
-H "Authorization: Bearer $ADMIN_JWT" -H 'Content-Type: application/json' \
|
|
534
|
+
-d '{"email":"ada@example.com","ttl":24,"context":{"redirectTo":"/onboarding"}}'
|
|
781
535
|
```
|
|
782
536
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
1. Enable proxy trust in `config/server.{js,ts}`:
|
|
796
|
-
|
|
797
|
-
```js
|
|
798
|
-
module.exports = ({ env }) => ({
|
|
799
|
-
proxy: true, // Trust X-Forwarded-For from the proxy
|
|
800
|
-
// ... rest of config
|
|
801
|
-
});
|
|
802
|
-
```
|
|
803
|
-
|
|
804
|
-
2. Configure the proxy to **strip any incoming `X-Forwarded-For`** and
|
|
805
|
-
emit only its own value. Without this, an attacker can spoof the
|
|
806
|
-
header, defeating per-IP rate limits.
|
|
807
|
-
|
|
808
|
-
3. If you have multiple proxy hops, pin trust to the outermost ones via
|
|
809
|
-
Koa's `app.proxyIpHeader` / `app.maxIpsCount` (see Koa docs).
|
|
810
|
-
|
|
811
|
-
A misconfigured deployment will either:
|
|
812
|
-
- **Count all requests as the proxy's single IP** (everyone rate-limited together), or
|
|
813
|
-
- **Trust spoofed client IPs** (per-IP rate limit effectively disabled).
|
|
814
|
-
|
|
815
|
-
---
|
|
816
|
-
|
|
817
|
-
## π OTP Strict Binding
|
|
818
|
-
|
|
819
|
-
Starting with this release, `/otp/send`, `/otp/verify` and `/otp/resend`
|
|
820
|
-
require the client to present the `magicLinkToken` they received from
|
|
821
|
-
`/magic-link/login` when it returned `{ requiresOTP: true }`. This prevents
|
|
822
|
-
`/otp/send` from being abused as a standalone email-bombing vector against
|
|
823
|
-
arbitrary addresses and prevents `/otp/verify` from being a first-factor
|
|
824
|
-
login on its own.
|
|
825
|
-
|
|
826
|
-
If you have legacy integrations that must keep the old behaviour, disable
|
|
827
|
-
it via settings: `otp_strict_binding: false`. Doing so emits a WARN on
|
|
828
|
-
every request and is **not recommended**.
|
|
829
|
-
|
|
830
|
-
---
|
|
831
|
-
|
|
832
|
-
## π¨ Email Templates
|
|
833
|
-
|
|
834
|
-
Customize your magic link emails using template variables:
|
|
835
|
-
|
|
836
|
-
```html
|
|
837
|
-
<!-- HTML Template -->
|
|
838
|
-
<h1>Welcome!</h1>
|
|
839
|
-
<p>Click to login:</p>
|
|
840
|
-
<a href="<%= URL %>?loginToken=<%= CODE %>">
|
|
841
|
-
Login to Your Account
|
|
842
|
-
</a>
|
|
537
|
+
```json
|
|
538
|
+
{
|
|
539
|
+
"id": 42,
|
|
540
|
+
"documentId": "k2m9x0...",
|
|
541
|
+
"email": "ada@example.com",
|
|
542
|
+
"expires_at": "2026-11-08T10:00:00.000Z",
|
|
543
|
+
"is_active": true,
|
|
544
|
+
"is_used": false,
|
|
545
|
+
"context": { "redirectTo": "/onboarding" },
|
|
546
|
+
"sent": true
|
|
547
|
+
}
|
|
843
548
|
```
|
|
844
549
|
|
|
845
|
-
|
|
846
|
-
-
|
|
847
|
-
-
|
|
848
|
-
|
|
849
|
-
---
|
|
850
|
-
|
|
851
|
-
## π Security Features
|
|
852
|
-
|
|
853
|
-
- **Token Expiration** - Configurable expiration periods
|
|
854
|
-
- **One-time Tokens** - Optional single-use tokens
|
|
855
|
-
- **IP Tracking** - Monitor login locations
|
|
856
|
-
- **IP Banning** - Block suspicious addresses
|
|
857
|
-
- **JWT Blacklist** - Revoke compromised sessions
|
|
858
|
-
- **Login Attempt Limiting** - Prevent brute force
|
|
859
|
-
- **User Agent Tracking** - Device fingerprinting
|
|
860
|
-
|
|
861
|
-
---
|
|
862
|
-
|
|
863
|
-
## π― Use Cases
|
|
864
|
-
|
|
865
|
-
- **SaaS Applications** - Simplify user onboarding
|
|
866
|
-
- **Customer Portals** - Secure, password-free access
|
|
867
|
-
- **Multi-tenant Systems** - Easy user management
|
|
868
|
-
- **Mobile Apps** - Seamless authentication flow
|
|
869
|
-
- **Content Platforms** - Reduce password fatigue
|
|
870
|
-
|
|
871
|
-
---
|
|
872
|
-
|
|
873
|
-
## π Troubleshooting
|
|
874
|
-
|
|
875
|
-
### Common Issues
|
|
876
|
-
|
|
877
|
-
| Issue | Solution |
|
|
878
|
-
|-------|----------|
|
|
879
|
-
| **Emails not sending** | Check Strapi email provider configuration in `config/plugins.js` |
|
|
880
|
-
| **Token invalid errors** | Verify token hasn't expired. Check `expire_period` setting |
|
|
881
|
-
| **User not found** | Enable `createUserIfNotExists` setting in admin panel |
|
|
882
|
-
| **License activation fails** | Check network connectivity. Try manual activation with license key |
|
|
883
|
-
| **npm install fails** | Use `npm install --legacy-peer-deps` or update to latest npm |
|
|
884
|
-
| **OTP settings not visible** | Check license tier. Email OTP requires Premium license |
|
|
885
|
-
| **TOTP not working** | Verify Advanced license is active. Check authenticator app time sync |
|
|
886
|
-
| **"Current License: Free" but Premium activated** | Refresh browser cache. License info loads from `/magic-link/license/status` |
|
|
887
|
-
| **Lock icons not centered** | Update to v4.16.1+. Mobile UI fixes are included |
|
|
888
|
-
| **Email OTP shown without license** | Update to v4.16.3+. License validation is fixed |
|
|
550
|
+
- With `send_email: false` the link is not mailed. The response then also carries `token` and `loginUrl`, once, which lets the admin log in as that user. That needs the separate `tokens.issue` permission (otherwise `403 PolicyError` with `details.key: "tokens.issue.forbidden"`).
|
|
551
|
+
- An unknown address gets an account only when `createUserIfNotExists` is on; otherwise `400`. A blocked user answers `400`.
|
|
552
|
+
- Creation is limited to 60 per admin per 15 minutes (`429` with `details.retryAfter`).
|
|
553
|
+
- If the e-mail cannot be sent, the token is deactivated and the answer is `503`.
|
|
889
554
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
Enable debug logging in your Strapi instance:
|
|
555
|
+
#### Example: revoke a session and ban an address
|
|
893
556
|
|
|
894
557
|
```bash
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
#
|
|
558
|
+
curl -X POST http://localhost:1337/magic-link/revoke-jwt \
|
|
559
|
+
-H "Authorization: Bearer $ADMIN_JWT" -H 'Content-Type: application/json' \
|
|
560
|
+
-d '{"sessionId":"session_1762500000000_a1b2c3d4e5f6"}'
|
|
561
|
+
# { "success": true, "message": "JWT session revoked successfully" } (404 when the id is unknown)
|
|
562
|
+
|
|
563
|
+
curl -X POST http://localhost:1337/magic-link/ban-ip \
|
|
564
|
+
-H "Authorization: Bearer $ADMIN_JWT" -H 'Content-Type: application/json' \
|
|
565
|
+
-d '{"data":{"ip":"203.0.113.50"}}'
|
|
566
|
+
# { "success": true, "message": "IP 203.0.113.50 has been banned" } (400 for an invalid address)
|
|
899
567
|
```
|
|
900
568
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
569
|
+
## Permissions
|
|
570
|
+
|
|
571
|
+
### Admin roles (Settings > Administration Panel > Roles > Plugins > Magic Link)
|
|
572
|
+
|
|
573
|
+
| Action | Grants |
|
|
574
|
+
| -------------------------------------- | ------------------------------------------------------------------------ |
|
|
575
|
+
| `plugin::magic-link.read` | Menu entry and Tokens page: tokens, OTP codes, JWT sessions, IP bans |
|
|
576
|
+
| `plugin::magic-link.manage` | Create, block, extend, delete tokens; revoke sessions; ban IPs; cleanups |
|
|
577
|
+
| `plugin::magic-link.tokens.issue` | Create links without e-mail (impersonation). Grant sparingly. |
|
|
578
|
+
| `plugin::magic-link.settings.read` | Settings and License pages |
|
|
579
|
+
| `plugin::magic-link.settings.update` | Save settings, rate-limit actions, license changes |
|
|
580
|
+
| `plugin::magic-link.settings.security` | Change security settings (see below), reset MFA settings |
|
|
581
|
+
|
|
582
|
+
The server enforces these on every route; the admin pages hide what a role cannot do. Super Admin has all of them.
|
|
583
|
+
|
|
584
|
+
**Role migration from 5.x.** 5.x had a single `plugin::magic-link.access` action. On the first start of 6.0.0, every admin role (and admin API token) that still holds it gets **Read** and **Manage**, and the old permission is removed. `tokens.issue`, `settings.read`, `settings.update` and `settings.security` are never granted automatically. After the upgrade, open each role and grant **Settings > Read** to roles that used the License page. If Strapi already dropped the unknown `access` action before the migration ran, there is nothing to migrate: grant the new actions by hand.
|
|
585
|
+
|
|
586
|
+
**Security settings** need `settings.security` in addition to `settings.update`. They can redirect login links (and the login token in them) to another site, put the raw token into a template, or change who may log in and with which factors: `confirmationUrl`, `callback_url`, `frontend_url`, `frontend_login_path`, `login_path`, `passwordlessCompatibility`, `message_html`, `message_text`, the Email Designer and Magic Mail template fields, `enabled`, `createUserIfNotExists`, `user_creation_strategy`, `allow_magic_links_on_public_registration`, `verify_email`, `stays_valid`, `expire_period`, `token_length`, `use_jwt_token`, `jwt_token_expires_in`, `context_whitelist`, `context_blacklist`, `mfa_mode`, `mfa_require_totp`, `totp_as_primary_auth`, `otp_enabled`, `otp_strict_binding`, and the code and brute-force rules `otp_length`, `otp_expiry`, `otp_max_attempts`, `otp_resend_cooldown`, `totp_algorithm`, `totp_digits`, `totp_period`, `rate_limit_enabled`, `rate_limit_max_attempts`, `rate_limit_window_minutes`, `rate_limit_login_*`, and the sender identity `from_name`, `from_email` and `response_email`.
|
|
587
|
+
|
|
588
|
+
- Sending them unchanged is allowed, so a role with `settings.update` only can still save the subject, welcome mail, login-info and TOTP issuer.
|
|
589
|
+
- `reset-data` needs `settings.security`.
|
|
590
|
+
- A refused change answers `403` with `error.details.key: 'settings.security.forbidden'` and the field names; nothing is written.
|
|
591
|
+
- `settings.security` is never granted automatically. Grant it only to roles that may decide where login links point (Super Admin has it).
|
|
592
|
+
|
|
593
|
+
For a hard limit that no admin can change, list the allowed link origins in the server config (`allowedLinkOrigins`, see [Plugin config](#plugin-config-configpluginsjs)).
|
|
594
|
+
|
|
595
|
+
### Users & Permissions roles
|
|
596
|
+
|
|
597
|
+
- **Public:** nothing to grant. The login routes are public (`auth: false`) and protected by rate limits and IP bans.
|
|
598
|
+
- **Authenticated:** grant the **Magic-link > totp** actions if users should manage their authenticator themselves.
|
|
599
|
+
|
|
600
|
+
## Configuration reference
|
|
601
|
+
|
|
602
|
+
### Settings (admin panel)
|
|
603
|
+
|
|
604
|
+
Stored in the plugin store and edited in **Settings > Magic Link > Settings**. Unknown keys are ignored.
|
|
605
|
+
|
|
606
|
+
| Key | Default | Meaning |
|
|
607
|
+
| ---------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------- |
|
|
608
|
+
| `enabled` | `true` | Master switch |
|
|
609
|
+
| `expire_period` | `3600` | Link lifetime in seconds (10 to 31536000) |
|
|
610
|
+
| `token_length` | `32` | Link token length, 22 to 128 characters |
|
|
611
|
+
| `stays_valid` | `false` | Allow a link to be used more than once until it expires |
|
|
612
|
+
| `frontend_url`, `frontend_login_path` | `''`, `/auth/magic-link` | Landing page for links (https; http only for localhost) |
|
|
613
|
+
| `confirmationUrl` | `''` | Alternative link target when no frontend URL is set |
|
|
614
|
+
| `createUserIfNotExists` | `true` | Allow auto-registration (needs the next one and sign-ups as well) |
|
|
615
|
+
| `allow_magic_links_on_public_registration` | `false` | Send links to unknown addresses and create the account on first login |
|
|
616
|
+
| `user_creation_strategy` | `email` | `email`, `emailUsername` or `manual` username for new accounts |
|
|
617
|
+
| `use_jwt_token`, `jwt_token_expires_in` | `true`, `30d` | Issue a JWT; lifetime in legacy mode (`15m`, `12h`, `30d`) |
|
|
618
|
+
| `store_login_info` | `true` | Record IP address and user agent on the token |
|
|
619
|
+
| `context_whitelist`, `context_blacklist` | `[]`, `[password, ...]` | Context keys allowed or always removed |
|
|
620
|
+
| `from_name`, `from_email`, `response_email` | see admin | Sender; an empty `from_email` uses the e-mail plugin `defaultFrom` |
|
|
621
|
+
| `object`, `message_html`, `message_text` | built-in template | Subject and bodies, see [E-mail templates](#e-mail-templates) |
|
|
622
|
+
| `use_magic_mail`, `magic_mail_template_id` | `false` | Send through Magic Mail |
|
|
623
|
+
| `use_email_designer`, `email_designer_template_id` | `false` | Send through Email Designer 5 |
|
|
624
|
+
| `otp_enabled` | `false` | E-mail OTP after the link |
|
|
625
|
+
| `otp_length`, `otp_expiry` | `6`, `300` | Code length (4 to 16) and lifetime in seconds |
|
|
626
|
+
| `otp_max_attempts`, `otp_resend_cooldown` | `3`, `60` | Attempts per code; seconds before a resend |
|
|
627
|
+
| `otp_strict_binding` | `true` | OTP routes need the `magicLinkToken` from `/login`. Keep it on. |
|
|
628
|
+
| `mfa_mode` | `disabled` | `disabled`, `optional` or `required` TOTP after the link |
|
|
629
|
+
| `totp_as_primary_auth` | `false` | Enable `POST /login-totp` |
|
|
630
|
+
| `totp_issuer`, `totp_algorithm`, `totp_digits`, `totp_period` | `Magic Link`, `SHA1`, `6`, `30` | Defaults for new enrolments |
|
|
631
|
+
| `rate_limit_enabled` | `true` | Rate limiting on the public routes |
|
|
632
|
+
| `rate_limit_max_attempts`, `rate_limit_window_minutes` | `5`, `15` | Requests per IP and per address in the window |
|
|
633
|
+
| `rate_limit_login_max_attempts`, `rate_limit_login_window_minutes`, `rate_limit_login_block_minutes` | `10`, `15`, `30` | Link exchanges and TOTP logins per IP, and the block after the limit |
|
|
634
|
+
| `passwordlessCompatibility` | `false` | Serve `/api/passwordless/*` |
|
|
635
|
+
|
|
636
|
+
### Plugin config (`config/plugins.js`)
|
|
637
|
+
|
|
638
|
+
```js
|
|
639
|
+
module.exports = ({ env }) => ({
|
|
640
|
+
'magic-link': {
|
|
641
|
+
config: {
|
|
642
|
+
// Shared rate limits for several Strapi instances (rate-limiter-flexible Redis store).
|
|
643
|
+
rateLimit: { redis: { storeClient: redisClient, useRedisPackage: true } },
|
|
644
|
+
// Disable all calls to the license server.
|
|
645
|
+
license: { remoteLookup: false },
|
|
646
|
+
// Optional: login links may only point at these origins. A changed
|
|
647
|
+
// confirmationUrl, frontend_url or callback_url outside the list is
|
|
648
|
+
// rejected with 400 (key settings.linkOrigin.notAllowed).
|
|
649
|
+
allowedLinkOrigins: ['https://app.example.com'],
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
});
|
|
910
653
|
```
|
|
911
654
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
- π [GitHub Issues](https://github.com/Schero94/strapi-plugin-magic-link-v5/issues)
|
|
915
|
-
- π Check the README for updated examples
|
|
916
|
-
|
|
917
|
-
---
|
|
918
|
-
|
|
919
|
-
## π€ Contributing
|
|
655
|
+
`allowedLinkOrigins` entries must be bare origins such as `https://app.example.com`; anything else stops Strapi at startup with a clear error.
|
|
920
656
|
|
|
921
|
-
|
|
657
|
+
### Environment variables
|
|
922
658
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
659
|
+
| Variable | Required | Purpose |
|
|
660
|
+
| ------------------------------------ | -------- | ------------------------------------------------------------- |
|
|
661
|
+
| `MAGIC_LINK_ENCRYPTION_KEY` | yes | AES-256-GCM key for TOTP secrets, 32+ characters |
|
|
662
|
+
| `MAGIC_LINK_OTP_PEPPER` | yes | Key for OTP code hashes and backup-code HMACs, 32+ characters |
|
|
663
|
+
| `MAGIC_LINK_ENCRYPTION_KEY_PREVIOUS` | no | Old key during rotation; secrets are re-encrypted at startup |
|
|
664
|
+
| `MAGIC_LINK_ALLOW_DEV_SECRETS` | no | `true` allows derived development secrets outside production |
|
|
665
|
+
| `MAGIC_LINK_LICENSE_LOOKUP` | no | `false` disables license-server calls |
|
|
928
666
|
|
|
929
|
-
**
|
|
930
|
-
- `feat:` - New features
|
|
931
|
-
- `fix:` - Bug fixes
|
|
932
|
-
- `docs:` - Documentation changes
|
|
933
|
-
- `chore:` - Maintenance tasks
|
|
667
|
+
**Rotating the encryption key:**
|
|
934
668
|
|
|
935
|
-
|
|
669
|
+
1. Put the new key in `MAGIC_LINK_ENCRYPTION_KEY` and the old one in `MAGIC_LINK_ENCRYPTION_KEY_PREVIOUS`.
|
|
670
|
+
2. Restart Strapi. TOTP secrets are re-encrypted at startup.
|
|
671
|
+
3. Remove `MAGIC_LINK_ENCRYPTION_KEY_PREVIOUS`.
|
|
936
672
|
|
|
937
|
-
|
|
673
|
+
Changing the pepper only invalidates pending e-mail codes and backup codes.
|
|
938
674
|
|
|
939
|
-
###
|
|
675
|
+
### Auto-registration
|
|
940
676
|
|
|
941
|
-
|
|
942
|
-
- **WhatsApp Magic Links** - Send authentication links via WhatsApp messenger (FREE!)
|
|
943
|
-
- **Customizable Templates** - Configure WhatsApp message text with variables
|
|
944
|
-
- **Frontend URL Setting** - Set custom frontend login URLs for magic links
|
|
945
|
-
- **Magic-Mail Integration** - Automatic delegation when both plugins installed
|
|
946
|
-
- **QR Code Setup** - Easy WhatsApp connection via QR code scan
|
|
947
|
-
- **Debug Mode** - Optional verbose logging for WhatsApp connections
|
|
677
|
+
An unknown address gets an account only when all three are on:
|
|
948
678
|
|
|
949
|
-
|
|
950
|
-
- `
|
|
951
|
-
-
|
|
952
|
-
- `whatsapp_app_name` - App name displayed in WhatsApp messages
|
|
953
|
-
- `whatsapp_debug` - Enable/disable verbose WhatsApp logging
|
|
679
|
+
- `createUserIfNotExists`
|
|
680
|
+
- `allow_magic_links_on_public_registration`
|
|
681
|
+
- users-permissions **Enable sign-ups** (`allow_register`)
|
|
954
682
|
|
|
955
|
-
|
|
683
|
+
New accounts start unconfirmed and are confirmed by their first magic-link login. If a username is already taken, a random suffix is added.
|
|
956
684
|
|
|
957
|
-
|
|
958
|
-
- **Passwordless Plugin Compatibility** - Migration support for `strapi-plugin-passwordless`
|
|
959
|
-
- Added `/api/passwordless/send-link` and `/api/passwordless/login` routes
|
|
960
|
-
- Identical request/response format - no frontend changes needed!
|
|
961
|
-
- Disabled by default for security (returns 404 when off)
|
|
962
|
-
- **Email Designer 5 Integration** - Enhanced template support
|
|
963
|
-
- New "Compatibility" settings section in admin UI
|
|
964
|
-
- Full i18n support for compatibility settings (DE, EN, ES, FR, PT)
|
|
965
|
-
- `compatibility-check` policy for secure route access
|
|
685
|
+
## E-mail templates
|
|
966
686
|
|
|
967
|
-
|
|
687
|
+
Subject, HTML and text bodies use `{{PLACEHOLDER}}` syntax. Values are HTML-escaped in the HTML body, and no template code is ever executed. The admin preview and real e-mails use the same renderer.
|
|
968
688
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
689
|
+
| Placeholder | Value |
|
|
690
|
+
| ----------------- | ------------------------------------------- |
|
|
691
|
+
| `{{LOGIN_URL}}` | Full login link including the token |
|
|
692
|
+
| `{{URL}}` | Link target without the token |
|
|
693
|
+
| `{{CODE}}` | The login token on its own |
|
|
694
|
+
| `{{EXPIRY_TEXT}}` | Human-readable lifetime, such as "1 hour" |
|
|
695
|
+
| `{{EXPIRES_AT}}` | Expiry as an ISO date |
|
|
696
|
+
| `{{EMAIL}}` | Recipient address |
|
|
697
|
+
| `{{USER}}` | Username, or the address when there is none |
|
|
974
698
|
|
|
975
|
-
|
|
699
|
+
Templates from 5.x that use `<%= URL %>?loginToken=<%= CODE %>` are converted to `{{LOGIN_URL}}` at startup. Any other `<% ... %>` block is printed as plain text.
|
|
976
700
|
|
|
977
|
-
**
|
|
978
|
-
- β
"Magic Link Only" now always shows ACTIVE with FREE license
|
|
979
|
-
- β
Email OTP settings only visible with Premium+ license
|
|
980
|
-
- β
Auto-disable premium features when license doesn't support them
|
|
981
|
-
- β
Added useEffect hook for automatic license enforcement
|
|
701
|
+
**Magic Mail and Email Designer 5** receive these variables: `magicLink`, `loginUrl`, `url`, `code`, `token`, `email`, `expiryText`, `expiresAt`, `user.email`, `user.username`. Choose the template in **Settings > E-mail**. Magic Mail sends login mails as `transactional` without open or link tracking. If the provider fails, the plugin falls back to the Strapi e-mail plugin.
|
|
982
702
|
|
|
983
|
-
|
|
703
|
+
## Security model
|
|
984
704
|
|
|
985
|
-
|
|
986
|
-
- β
Perfectly centered lock/lightning icons in locked mode cards
|
|
987
|
-
- β
Fixed license validation in active state detection
|
|
988
|
-
- β
Locked features now show proper disabled state
|
|
989
|
-
- π¨ Improved locked overlay with flexbox centering
|
|
705
|
+
What is protected, and how:
|
|
990
706
|
|
|
991
|
-
|
|
707
|
+
- **Login links**
|
|
708
|
+
- Tokens have 132 or more bits of entropy and are stored as salted hashes. Only an 8-character lookup prefix is kept in clear.
|
|
709
|
+
- A single-use link is consumed with a conditional update, so parallel clicks yield exactly one session.
|
|
710
|
+
- Links expire after `expire_period`.
|
|
711
|
+
- **No account discovery.** `send-link` answers before any lookup or delivery: identical body, identical timing. `/login-totp` gives one answer for unknown accounts, missing authenticators and wrong codes.
|
|
712
|
+
- **Server-side challenge state.**
|
|
713
|
+
- Second-factor progress lives in a private token field, never in client-supplied context.
|
|
714
|
+
- Reserved context keys are rejected.
|
|
715
|
+
- Public and admin context pass the same allow/deny policy.
|
|
716
|
+
- **Second factors**
|
|
717
|
+
- OTP attempts are counted atomically before the comparison.
|
|
718
|
+
- Code checks (TOTP, backup and e-mail codes) are limited to 10 per account and 15 minutes, also when rate limiting is turned off. Neither the admin rate-limit reset nor a change of the public limits clears this counter.
|
|
719
|
+
- Every accepted TOTP time step is recorded, so a code cannot be replayed.
|
|
720
|
+
- TOTP secrets are encrypted with AES-256-GCM.
|
|
721
|
+
- Backup codes are owner-bound HMACs.
|
|
722
|
+
- Admin and end-user authenticators live in separate namespaces (`admin:<id>`, `up:<id>`).
|
|
723
|
+
- **Sessions**
|
|
724
|
+
- The JWT lifetime is honoured, and a revoked token stays blocked until it would have expired. A legacy JWT without `exp` (users-permissions configured without `expiresIn`) is blocked permanently, so its revocation never lapses; remove such an entry only with "restore".
|
|
725
|
+
- Every legacy JWT carries a random `jti`, so revoking one session never ends another session of the same user.
|
|
726
|
+
- In refresh mode, revocation ends the whole refresh family (rotated tokens included) and rejects access tokens minted from it (see [Refresh mode](#refresh-mode-users-permissions-sessions)).
|
|
727
|
+
- The `Authorization` header is parsed like users-permissions does it. The revocation check runs on Content API requests (REST and GraphQL) only and fails closed (503) there; admin routes are never affected. Paths are matched case-insensitively, like the Strapi routers (`/API/users/me` is checked too).
|
|
728
|
+
- **Pre-account hijacking.** With e-mail confirmation on, someone can register through `/api/auth/local/register` with another person's address and a password of their choice. When the mailbox owner then confirms that unconfirmed account through a magic link, an e-mail OTP or the TOTP enrolment during login, the plugin first ends every existing session of the account (plugin JWTs and all users-permissions refresh families), then confirms it with the password, reset token and confirmation token cleared. The owner sets a password through "forgot password" if they want password login. Already confirmed accounts are not touched.
|
|
729
|
+
- **MFA policy changes.** A challenge that was started before the MFA settings (or the user's authenticator) changed is re-checked when it completes. An e-mail OTP no longer completes a login once MFA is required or the user enrolled an authenticator; opening the link again starts the required challenge.
|
|
730
|
+
- **Scope of MFA.** The MFA settings apply to the Magic Link login paths. Other ways to get a users-permissions session, such as `/api/auth/local` with a password or a social provider, do not ask for a second factor. Disable the ones you do not use (accounts created by Magic Link have no password).
|
|
731
|
+
- **Transport and logs**
|
|
732
|
+
- Every public Magic Link response sends `Cache-Control: no-store` and `Referrer-Policy: no-referrer`.
|
|
733
|
+
- Plugin logs contain no e-mail addresses, tokens or URLs with tokens.
|
|
734
|
+
- **Admin**
|
|
735
|
+
- Permissions are enforced on the server for every route.
|
|
736
|
+
- Impersonation needs its own permission, and so do settings that redirect links or change identity rules (`settings.security`).
|
|
737
|
+
- The license key is masked for readers.
|
|
738
|
+
- **Templates.** Placeholder rendering only; there is no code execution.
|
|
992
739
|
|
|
993
|
-
|
|
994
|
-
- π¨ Complete MFA settings redesign with modern UI
|
|
995
|
-
- π License tier status display with visual indicators
|
|
996
|
-
- π Locked feature cards with clear premium/advanced badges
|
|
997
|
-
- π©πͺ Full German translations for all MFA settings
|
|
998
|
-
- β
Improved explanations and help texts
|
|
999
|
-
- π§ Better Email OTP configuration section
|
|
1000
|
-
- π Added step-by-step "How it works" guides
|
|
740
|
+
Known limitation: in a cluster, concurrent admin writes to the session list, blocklist or ban list can overwrite each other (see [cluster notes](#running-behind-a-proxy-or-in-a-cluster)).
|
|
1001
741
|
|
|
1002
|
-
|
|
742
|
+
Report vulnerabilities privately through [GitHub security advisories](https://github.com/Schero94/strapi-plugin-magic-link-v5/security/advisories/new).
|
|
1003
743
|
|
|
1004
|
-
|
|
1005
|
-
- β
Added Email OTP configuration section
|
|
1006
|
-
- βοΈ Code length, expiry time, max attempts settings
|
|
1007
|
-
- π Resend cooldown configuration
|
|
1008
|
-
- π§ OTP delivery method selection
|
|
1009
|
-
- βΉοΈ Improved help text and info boxes
|
|
744
|
+
## Running behind a proxy or in a cluster
|
|
1010
745
|
|
|
1011
|
-
|
|
746
|
+
Rate limits and IP bans need the real client IP. Behind nginx, Caddy, Traefik, a load balancer or a CDN, configure `config/server.js`:
|
|
1012
747
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
For full version history, see [GitHub Releases](https://github.com/Schero94/strapi-plugin-magic-link-v5/releases).
|
|
748
|
+
```js
|
|
749
|
+
module.exports = ({ env }) => ({
|
|
750
|
+
url: env('PUBLIC_URL', 'https://api.example.com'),
|
|
751
|
+
proxy: { koa: true, maxIpsCount: 1 }, // number of proxies in front of Strapi
|
|
752
|
+
});
|
|
753
|
+
```
|
|
1020
754
|
|
|
1021
|
-
|
|
755
|
+
Without `maxIpsCount`, clients can spoof `X-Forwarded-For`. The plugin logs a warning at startup when `proxy.koa` is on and `maxIpsCount` is missing.
|
|
1022
756
|
|
|
1023
|
-
|
|
757
|
+
**IP bans** match like the rate limits: IPv4 (and IPv4-mapped IPv6) exactly, IPv6 per /64 network. Banning one IPv6 address bans its whole /64, and unbanning any address of that /64 removes the ban.
|
|
1024
758
|
|
|
1025
|
-
|
|
759
|
+
**Several instances:**
|
|
1026
760
|
|
|
1027
|
-
|
|
761
|
+
- Limits are in memory by default. For shared limits, set `rateLimit.redis` in the plugin config.
|
|
762
|
+
- The session list, the JWT blocklist and the ban list are stored in the plugin store. Writes are serialised within one process only, so concurrent writes from several instances can still overwrite each other (AUTH-14, not solved). In practice: two admins revoking sessions or banning IPs on different instances at the same moment can lose one of the two changes; repeat the action and check the list afterwards. Logins only append session records and are not affected by a lost revocation, but a lost revocation leaves that token valid.
|
|
763
|
+
- Each instance caches the JWT blocklist for up to 2 seconds, so a revocation reaches other instances within that time.
|
|
764
|
+
- Login tokens and OTP codes are single use across instances (conditional database updates).
|
|
1028
765
|
|
|
1029
|
-
|
|
766
|
+
## Migrating from strapi-plugin-passwordless
|
|
1030
767
|
|
|
1031
|
-
|
|
768
|
+
1. Install Magic Link and remove `strapi-plugin-passwordless`.
|
|
769
|
+
2. Enable **Passwordless compatibility** in **Settings > Advanced**.
|
|
770
|
+
3. Your existing calls to `POST /api/passwordless/send-link` and `GET /api/passwordless/login?loginToken=` keep working. They are rewritten to the Magic Link routes and honour `api.rest.prefix`.
|
|
1032
771
|
|
|
1033
|
-
|
|
772
|
+
When the setting is off, those paths are passed on to Strapi untouched.
|
|
1034
773
|
|
|
1035
|
-
|
|
1036
|
-
- π‘ **Feature Requests**: [GitHub Discussions](https://github.com/Schero94/strapi-plugin-magic-link-v5/discussions)
|
|
1037
|
-
- π¦ **npm Package**: [strapi-plugin-magic-link-v5](https://www.npmjs.com/package/strapi-plugin-magic-link-v5)
|
|
774
|
+
## Troubleshooting
|
|
1038
775
|
|
|
1039
|
-
|
|
776
|
+
| Symptom | Fix |
|
|
777
|
+
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
778
|
+
| Strapi does not start: "MAGIC_LINK_ENCRYPTION_KEY ..." | Set both secrets (32+ characters) or `MAGIC_LINK_ALLOW_DEV_SECRETS=true` locally |
|
|
779
|
+
| `send-link` says sent but no e-mail arrives | The address has no account (and auto-registration is off), or the e-mail provider failed. Check the Strapi log for `[magic-link]` lines. |
|
|
780
|
+
| Link opens but answers `token.invalid` | The link expired, was already used, was blocked, or the token was cut off by the mail client |
|
|
781
|
+
| `/otp/send` answers 400 "magicLinkToken is required" | Call `/login` first and pass its `loginToken` as `magicLinkToken` |
|
|
782
|
+
| Settings refuse my URL | Use `https://` (http only for `localhost`), and check `allowedLinkOrigins` |
|
|
783
|
+
| Everyone shares one rate limit / bans hit the proxy | Configure `server.proxy` (see above) |
|
|
784
|
+
| 403 with `IP_ADDRESS_BLOCKED` | The IP is banned. Unban it in **Magic Link > IP Bans**. |
|
|
785
|
+
| 400 "Only e-mail delivery is supported" | Remove `phoneNumber`, `phone`, `via` and non-e-mail `delivery` from the request |
|
|
786
|
+
| Users cannot reach `/totp/status` (403) | Grant the **totp** actions to the Authenticated role |
|
|
787
|
+
| Saving settings answers 403 `settings.security.forbidden` | The role lacks **Settings > Security** for the listed fields |
|
|
788
|
+
| Admins see no Magic Link menu after upgrading | Grant **Read** (and **Manage**) to their role; see the upgrade guide |
|
|
1040
789
|
|
|
1041
|
-
|
|
1042
|
-
- π» **Source Code**: [GitHub Repository](https://github.com/Schero94/strapi-plugin-magic-link-v5)
|
|
1043
|
-
- π― **Live Example**: Coming soon
|
|
1044
|
-
- πΉ **Video Tutorial**: Coming soon
|
|
790
|
+
## Upgrading from 5.x to 6.0.0
|
|
1045
791
|
|
|
1046
|
-
|
|
792
|
+
6.0.0 is a major release focused on security. Work through this list:
|
|
1047
793
|
|
|
1048
|
-
|
|
794
|
+
1. **Runtime.** Node.js 22+ and Strapi 5.24.0+.
|
|
795
|
+
2. **Secrets.** Set `MAGIC_LINK_ENCRYPTION_KEY` and `MAGIC_LINK_OTP_PEPPER` (32+ characters each). If your old encryption key was shorter, or you relied on the development fallback, put the old value in `MAGIC_LINK_ENCRYPTION_KEY_PREVIOUS` for the first start. For the old fallback that value is your `ADMIN_JWT_SECRET`.
|
|
796
|
+
3. **E-mail only.** Remove WhatsApp/SMS fields from `send-link` calls. Delete a leftover `.whatsapp-auth` folder from releases up to 5.2.x.
|
|
797
|
+
4. **Admin roles.** `access` became `read`, `manage`, `settings.read` and `settings.update`, plus `tokens.issue` for impersonation and `settings.security` for security settings.
|
|
798
|
+
- On startup, roles holding `access` get **Read** and **Manage** automatically (see [role migration](#permissions)).
|
|
799
|
+
- Check each role, and grant **Settings > Read** to roles that used the License page.
|
|
800
|
+
- Grant **Settings > Security** only to roles that may change link targets, templates, account and MFA rules. It is never granted automatically.
|
|
801
|
+
5. **Frontend.**
|
|
802
|
+
- Exchange the token with `POST /login { loginToken }`.
|
|
803
|
+
- Handle `requiresTOTPSetup` if you use required MFA.
|
|
804
|
+
- Read `expires_at` instead of the removed `expiry_formatted`.
|
|
805
|
+
- Read errors from `error.key` (see [Errors](#errors) for the answers without a key).
|
|
806
|
+
6. **Templates.** Replace JavaScript in templates with `{{PLACEHOLDERS}}`. Plain `<%= VAR %>` placeholders are converted automatically.
|
|
807
|
+
7. **URLs.** `frontend_url`, `confirmationUrl` and `callback_url` must be `https` (http only for localhost).
|
|
808
|
+
8. **Tokens.** `token_length` is now 22 to 128. Unhashed links from very old releases stop working.
|
|
809
|
+
9. **Authenticators.**
|
|
810
|
+
- Existing enrolments keep working where ownership can be proven. The others are disabled and the users enrol again.
|
|
811
|
+
- Old backup codes are dropped.
|
|
812
|
+
- Grant the **totp** actions to the Authenticated role for self-service.
|
|
813
|
+
10. **Admin token API.** Send `ttl` (hours) as a top-level field. Creating links without e-mail needs `tokens.issue`. Several unused admin routes were removed.
|
|
1049
814
|
|
|
1050
|
-
|
|
1051
|
-
- π Report bugs and suggest features
|
|
1052
|
-
- π‘ Contribute code improvements
|
|
1053
|
-
- π’ Share with the Strapi community
|
|
815
|
+
The full list with every changed field is in [CHANGELOG.md](CHANGELOG.md#unreleased).
|
|
1054
816
|
|
|
1055
|
-
|
|
817
|
+
## Languages, license and support
|
|
1056
818
|
|
|
1057
|
-
|
|
819
|
+
- **Languages.** The admin panel follows the admin interface language: English, German, French, Spanish or Portuguese, with English as the fallback. Server error messages use the same five languages.
|
|
820
|
+
- **License.** MIT, see [LICENSE](LICENSE). The optional license key in the admin panel unlocks nothing, and every feature works without it. Disable license-server calls with `license.remoteLookup: false` or `MAGIC_LINK_LICENSE_LOOKUP=false`.
|
|
821
|
+
- **Support.** Report bugs and request features through [GitHub issues](https://github.com/Schero94/strapi-plugin-magic-link-v5/issues).
|
|
1058
822
|
|
|
1059
|
-
|
|
1060
|
-
release, but two transitive paths are governed by upstream packages we
|
|
1061
|
-
do not control. If your own `npm audit` flags any of the following,
|
|
1062
|
-
add the matching entry to your **Strapi project's** `package.json`
|
|
1063
|
-
(not this plugin's β npm `overrides` only take effect on the
|
|
1064
|
-
root project):
|
|
1065
|
-
|
|
1066
|
-
```jsonc
|
|
1067
|
-
{
|
|
1068
|
-
"overrides": {
|
|
1069
|
-
// GHSA-xq3m-2v4x-88gg β protobufjs <7.5.5 arbitrary code execution.
|
|
1070
|
-
// Reaches us via @whiskeysockets/baileys β libsignal β protobufjs.
|
|
1071
|
-
// We already ship this override for local development; apply it in
|
|
1072
|
-
// your Strapi project so the fix propagates to the installed tree.
|
|
1073
|
-
"protobufjs": "^7.5.5",
|
|
1074
|
-
|
|
1075
|
-
// Optional: if you do not use the WhatsApp delivery feature, you
|
|
1076
|
-
// can also skip installing @whiskeysockets/baileys altogether:
|
|
1077
|
-
// npm install --omit=optional
|
|
1078
|
-
// (The Magic-Link code path detects the missing module and
|
|
1079
|
-
// disables the WhatsApp controller at runtime β the rest of the
|
|
1080
|
-
// plugin keeps working.)
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
```
|
|
1084
|
-
|
|
1085
|
-
After editing `package.json`, run:
|
|
823
|
+
### Contributing
|
|
1086
824
|
|
|
1087
825
|
```bash
|
|
1088
|
-
|
|
1089
|
-
npm
|
|
1090
|
-
npm
|
|
826
|
+
npm ci --legacy-peer-deps
|
|
827
|
+
npm run lint && npm run format:check && npm test
|
|
828
|
+
npm run build && npm run verify
|
|
1091
829
|
```
|
|
1092
830
|
|
|
1093
|
-
|
|
1094
|
-
place. The remaining `@strapi/design-system β lodash` advisory is a
|
|
1095
|
-
Strapi-core issue and will clear up automatically when you upgrade
|
|
1096
|
-
`@strapi/strapi` to the latest patch release.
|
|
1097
|
-
|
|
1098
|
-
---
|
|
1099
|
-
|
|
1100
|
-
<div align="center">
|
|
1101
|
-
|
|
1102
|
-
**Made with β€οΈ by [Schero94](https://github.com/Schero94)**
|
|
831
|
+
Commits follow Conventional Commits. Releases are published by semantic-release with npm provenance.
|
|
1103
832
|
|
|
1104
|
-
|
|
833
|
+
### Dependency maintenance
|
|
1105
834
|
|
|
1106
|
-
|
|
1107
|
-
|
|
835
|
+
- Dependabot opens weekly grouped updates.
|
|
836
|
+
- Patch and minor updates merge automatically once lint, format check, tests, build and verify pass.
|
|
837
|
+
- Major updates wait for review.
|
|
838
|
+
- React, styled-components, react-intl, react-router-dom and `@strapi/*` majors are never proposed, because the Strapi admin host sets those versions.
|
|
839
|
+
- `npm audit` reports GHSA-mrhx-c7xc-m3w5 (zod below 4.6.6, memory exhaustion on oversized collections) through `@strapi/utils` 5.61, which pins zod 4.4.3. Magic Link validates with its own zod 4.6.7. Until Strapi ships a fix, hosts can force the patched zod with npm `overrides`:
|
|
1108
840
|
|
|
1109
|
-
|
|
841
|
+
```json
|
|
842
|
+
{ "overrides": { "@strapi/utils": { "zod": "^4.6.6" } } }
|
|
843
|
+
```
|