adonia 0.1.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/README.md +64 -0
- package/build/adonis_peers.d.ts +14 -0
- package/build/adonis_peers.js +56 -0
- package/build/cli.d.ts +2 -0
- package/build/cli.js +111 -0
- package/build/commands/add.d.ts +18 -0
- package/build/commands/add.js +246 -0
- package/build/commands/check.d.ts +4 -0
- package/build/commands/check.js +159 -0
- package/build/commands/diff.d.ts +2 -0
- package/build/commands/diff.js +74 -0
- package/build/commands/init.d.ts +6 -0
- package/build/commands/init.js +124 -0
- package/build/commands/list.d.ts +2 -0
- package/build/commands/list.js +34 -0
- package/build/config_merge.d.ts +10 -0
- package/build/config_merge.js +71 -0
- package/build/constants_merge.d.ts +4 -0
- package/build/constants_merge.js +85 -0
- package/build/copy.d.ts +8 -0
- package/build/copy.js +33 -0
- package/build/host.d.ts +15 -0
- package/build/host.js +85 -0
- package/build/registry/index.d.ts +26 -0
- package/build/registry/index.js +116 -0
- package/build/registry/plan.d.ts +53 -0
- package/build/registry/plan.js +159 -0
- package/build/schema.d.ts +136 -0
- package/build/schema.js +58 -0
- package/build/types.d.ts +21 -0
- package/build/types.js +1 -0
- package/build/wire.d.ts +16 -0
- package/build/wire.js +162 -0
- package/package.json +51 -0
- package/registry/README.md +86 -0
- package/registry/account/contracts/avatar_storage.ts +7 -0
- package/registry/account/contracts/index.ts +7 -0
- package/registry/account/contracts/types.ts +23 -0
- package/registry/account/events.ts +14 -0
- package/registry/account/module.json +31 -0
- package/registry/account/options.ts +11 -0
- package/registry/account/service.ts +155 -0
- package/registry/account/stubs/adapters/account.ts +21 -0
- package/registry/account/stubs/adapters/drive_avatar_storage.ts +15 -0
- package/registry/account/stubs/controllers/account_controller.ts +190 -0
- package/registry/account/stubs/routes/account.ts +53 -0
- package/registry/account/stubs/validators/account.ts +37 -0
- package/registry/account/tests/account_service.spec.ts +132 -0
- package/registry/account/tests/fakes/memory_avatar_storage.ts +12 -0
- package/registry/api/envelope.ts +66 -0
- package/registry/api/exception.ts +38 -0
- package/registry/api/exception_handler.ts +54 -0
- package/registry/api/module.json +27 -0
- package/registry/api/provider.ts +129 -0
- package/registry/api/service.ts +88 -0
- package/registry/api/tests/emit_safe.spec.ts +107 -0
- package/registry/api/tests/respond.spec.ts +40 -0
- package/registry/auth/contracts/index.ts +18 -0
- package/registry/auth/contracts/password_reset_store.ts +21 -0
- package/registry/auth/contracts/session_manager.ts +19 -0
- package/registry/auth/contracts/types.ts +32 -0
- package/registry/auth/events.ts +21 -0
- package/registry/auth/module.json +45 -0
- package/registry/auth/options.ts +23 -0
- package/registry/auth/service.ts +150 -0
- package/registry/auth/stubs/adapters/auth.ts +21 -0
- package/registry/auth/stubs/adapters/lucid_access_token_session.ts +33 -0
- package/registry/auth/stubs/adapters/lucid_password_reset_store.ts +68 -0
- package/registry/auth/stubs/adapters/lucid_user_store.ts +86 -0
- package/registry/auth/stubs/adapters/map_user.ts +19 -0
- package/registry/auth/stubs/controllers/auth_controller.ts +126 -0
- package/registry/auth/stubs/migrations/add_pending_email_to_users_table.ts +17 -0
- package/registry/auth/stubs/migrations/create_password_resets_table.ts +27 -0
- package/registry/auth/stubs/migrations/create_users_table.ts +31 -0
- package/registry/auth/stubs/models/password_reset.ts +29 -0
- package/registry/auth/stubs/models/user.ts +99 -0
- package/registry/auth/stubs/providers/vine_provider.ts +43 -0
- package/registry/auth/stubs/routes/auth.ts +36 -0
- package/registry/auth/stubs/start/limiter.ts +17 -0
- package/registry/auth/stubs/validators/auth.ts +48 -0
- package/registry/auth/stubs/validators/rules/exists.ts +21 -0
- package/registry/auth/stubs/validators/rules/unique.ts +21 -0
- package/registry/auth/stubs/validators/rules/valid_password.ts +20 -0
- package/registry/auth/stubs/validators/rules/valid_token.ts +27 -0
- package/registry/auth/stubs/validators/vine.d.ts +10 -0
- package/registry/auth/tests/auth_service.spec.ts +145 -0
- package/registry/auth/tests/auth_update_password.spec.ts +99 -0
- package/registry/auth/tests/fakes/index.ts +3 -0
- package/registry/auth/tests/fakes/memory_password_reset_store.ts +71 -0
- package/registry/auth/tests/fakes/memory_session_manager.ts +23 -0
- package/registry/auth/tests/fakes/memory_user_store.ts +89 -0
- package/registry/constants/exceptions.ts +64 -0
- package/registry/constants/index.ts +12 -0
- package/registry/constants/responseCodes.ts +87 -0
- package/registry/contracts/index.ts +5 -0
- package/registry/contracts/user.ts +78 -0
- package/registry/creem/contracts/creem_client.ts +34 -0
- package/registry/creem/contracts/index.ts +8 -0
- package/registry/creem/contracts/types.ts +30 -0
- package/registry/creem/events.ts +9 -0
- package/registry/creem/module.json +28 -0
- package/registry/creem/service.ts +87 -0
- package/registry/creem/stubs/adapters/creem.ts +23 -0
- package/registry/creem/stubs/adapters/creem_io_client.ts +70 -0
- package/registry/creem/tests/fakes/fake_creem_client.ts +68 -0
- package/registry/notification/contracts/app_notification_store.ts +8 -0
- package/registry/notification/contracts/index.ts +9 -0
- package/registry/notification/contracts/mail_transport.ts +7 -0
- package/registry/notification/contracts/types.ts +32 -0
- package/registry/notification/module.json +23 -0
- package/registry/notification/options.ts +11 -0
- package/registry/notification/service.ts +103 -0
- package/registry/notification/stubs/adapters/adonis_mail_transport.ts +15 -0
- package/registry/notification/stubs/adapters/lucid_app_notification_store.ts +21 -0
- package/registry/notification/stubs/adapters/notification.ts +36 -0
- package/registry/notification/stubs/migrations/create_notifications_table.ts +23 -0
- package/registry/notification/stubs/models/app_notification.ts +45 -0
- package/registry/notification/tests/fakes/memory_app_notification_store.ts +19 -0
- package/registry/notification/tests/fakes/memory_mail_transport.ts +16 -0
- package/registry/notification/tests/notification_service.spec.ts +63 -0
- package/registry/registry.json +4 -0
- package/registry/subscription/contracts/index.ts +9 -0
- package/registry/subscription/contracts/subscription_store.ts +15 -0
- package/registry/subscription/contracts/types.ts +39 -0
- package/registry/subscription/events.ts +13 -0
- package/registry/subscription/module.json +31 -0
- package/registry/subscription/options.ts +14 -0
- package/registry/subscription/service.ts +140 -0
- package/registry/subscription/stubs/adapters/lucid_subscription_store.ts +69 -0
- package/registry/subscription/stubs/adapters/subscription.ts +21 -0
- package/registry/subscription/stubs/migrations/create_subscriptions_table.ts +26 -0
- package/registry/subscription/stubs/migrations/subscriptions_user_creem_unique.ts +19 -0
- package/registry/subscription/stubs/models/subscription.ts +45 -0
- package/registry/subscription/tests/billing_service.spec.ts +189 -0
- package/registry/subscription/tests/fakes/memory_subscription_store.ts +63 -0
- package/registry/types/api.ts +41 -0
- package/registry/types/constants.ts +7 -0
- package/registry/types/index.ts +6 -0
- package/registry/types/models.ts +32 -0
- package/schema/adonia.schema.json +39 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { DateTime } from 'luxon'
|
|
2
|
+
import hash from '@adonisjs/core/services/hash'
|
|
3
|
+
import { compose } from '@adonisjs/core/helpers'
|
|
4
|
+
import { BaseModel, column, computed } from '@adonisjs/lucid/orm'
|
|
5
|
+
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
|
|
6
|
+
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
|
|
7
|
+
import { ModelsTypes } from '#modules/types'
|
|
8
|
+
import encryption from '@adonisjs/core/services/encryption'
|
|
9
|
+
import env from '#start/env'
|
|
10
|
+
import { defaultAuthOptions } from '../../options.ts'
|
|
11
|
+
|
|
12
|
+
interface IUserModel extends ModelsTypes.IUserModel {}
|
|
13
|
+
|
|
14
|
+
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
|
|
15
|
+
uids: ['email'],
|
|
16
|
+
passwordColumnName: 'password',
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export default class User
|
|
20
|
+
extends compose(BaseModel, AuthFinder)
|
|
21
|
+
implements ModelsTypes.ModularModel<IUserModel>
|
|
22
|
+
{
|
|
23
|
+
static accessTokens = DbAccessTokensProvider.forModel(User)
|
|
24
|
+
|
|
25
|
+
nameSpace = 'user'
|
|
26
|
+
|
|
27
|
+
@column({ isPrimary: true, serializeAs: null })
|
|
28
|
+
declare id: number
|
|
29
|
+
|
|
30
|
+
@column()
|
|
31
|
+
declare authMethod: 'github' | 'google' | 'email'
|
|
32
|
+
|
|
33
|
+
@column()
|
|
34
|
+
declare email: string
|
|
35
|
+
|
|
36
|
+
@column()
|
|
37
|
+
declare pendingEmail: string | null
|
|
38
|
+
|
|
39
|
+
@column()
|
|
40
|
+
declare firstName: string
|
|
41
|
+
|
|
42
|
+
@column()
|
|
43
|
+
declare lastName: string
|
|
44
|
+
|
|
45
|
+
@column({ serializeAs: null })
|
|
46
|
+
declare avatarKey: string | null
|
|
47
|
+
|
|
48
|
+
@column({ serializeAs: null })
|
|
49
|
+
declare password: string | null
|
|
50
|
+
|
|
51
|
+
@column({ serializeAs: null })
|
|
52
|
+
declare emailVerificationToken: string | null
|
|
53
|
+
|
|
54
|
+
@column()
|
|
55
|
+
declare creemCustomerId: string | null
|
|
56
|
+
|
|
57
|
+
@column()
|
|
58
|
+
declare emailMarketing: boolean
|
|
59
|
+
|
|
60
|
+
@column()
|
|
61
|
+
declare emailProductUpdates: boolean
|
|
62
|
+
|
|
63
|
+
@column()
|
|
64
|
+
declare emailSecurityAlerts: boolean
|
|
65
|
+
|
|
66
|
+
@column()
|
|
67
|
+
declare emailWeeklyDigest: boolean
|
|
68
|
+
|
|
69
|
+
@computed()
|
|
70
|
+
get name() {
|
|
71
|
+
return this.firstName + ' ' + this.lastName
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@column.dateTime({ autoCreate: true })
|
|
75
|
+
declare createdAt: DateTime
|
|
76
|
+
|
|
77
|
+
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
|
78
|
+
declare updatedAt: DateTime | null
|
|
79
|
+
|
|
80
|
+
@column.dateTime()
|
|
81
|
+
declare emailVerifiedAt: DateTime | null
|
|
82
|
+
|
|
83
|
+
@computed()
|
|
84
|
+
get avatar() {
|
|
85
|
+
return this.avatarKey ? `${env.get('AVATAR_URL')}/${this.avatarKey}` : ''
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
verifyEmail() {
|
|
89
|
+
this.emailVerifiedAt = DateTime.now()
|
|
90
|
+
this.emailVerificationToken = null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
generateVerificationToken() {
|
|
94
|
+
this.emailVerificationToken = encryption.encrypt(
|
|
95
|
+
this.email,
|
|
96
|
+
defaultAuthOptions.verificationTtl
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers Vine macros used by auth validators: unique, exists,
|
|
3
|
+
* validToken, validPassword.
|
|
4
|
+
*
|
|
5
|
+
* Copy into providers/ and register in adonisrc.ts providers, after
|
|
6
|
+
* `@adonisjs/core/providers/vinejs_provider`:
|
|
7
|
+
*
|
|
8
|
+
* () => import('#providers/vine_provider'),
|
|
9
|
+
*/
|
|
10
|
+
import type { ApplicationService } from '@adonisjs/core/types'
|
|
11
|
+
import { VineString } from '@vinejs/vine'
|
|
12
|
+
import { uniqueRule } from '#validators/rules/unique'
|
|
13
|
+
import { existsRule } from '#validators/rules/exists'
|
|
14
|
+
import { validTokenRule } from '#validators/rules/valid_token'
|
|
15
|
+
import { validPasswordRule } from '#validators/rules/valid_password'
|
|
16
|
+
|
|
17
|
+
export default class VineMacrosProvider {
|
|
18
|
+
constructor(protected app: ApplicationService) {}
|
|
19
|
+
|
|
20
|
+
boot() {
|
|
21
|
+
VineString.macro(
|
|
22
|
+
'unique',
|
|
23
|
+
function (this: VineString, options: { table: string; column: string }) {
|
|
24
|
+
return this.use(uniqueRule(options))
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
VineString.macro(
|
|
28
|
+
'exists',
|
|
29
|
+
function (this: VineString, options: { table: string; column: string }) {
|
|
30
|
+
return this.use(existsRule(options))
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
VineString.macro(
|
|
34
|
+
'validToken',
|
|
35
|
+
function (this: VineString, options: { table: string; column: string }) {
|
|
36
|
+
return this.use(validTokenRule(options))
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
VineString.macro('validPassword', function (this: VineString) {
|
|
40
|
+
return this.use(validPasswordRule())
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example auth routes. Requires controllers (and typically validators)
|
|
3
|
+
* installed via `adonia add auth --with-controllers --with-validators`
|
|
4
|
+
* or `--with-stubs`.
|
|
5
|
+
*
|
|
6
|
+
* Copy Vine provider (`providers/vine_provider.ts`) and register it in
|
|
7
|
+
* adonisrc.ts. Logout needs named auth middleware from start/kernel.ts.
|
|
8
|
+
*
|
|
9
|
+
* Sensitive routes should use authLimiter (5/min). Install @adonisjs/limiter,
|
|
10
|
+
* copy stubs/start/limiter.ts, then uncomment `.use(authLimiter)` below.
|
|
11
|
+
*/
|
|
12
|
+
import router from '@adonisjs/core/services/router'
|
|
13
|
+
// import { middleware } from '#start/kernel'
|
|
14
|
+
// import { authLimiter } from '#start/limiter'
|
|
15
|
+
|
|
16
|
+
const AuthController = () => import('#controllers/auth_controller')
|
|
17
|
+
|
|
18
|
+
router
|
|
19
|
+
.group(() => {
|
|
20
|
+
router.post('register', [AuthController, 'register'])
|
|
21
|
+
// .use(authLimiter)
|
|
22
|
+
router.post('login', [AuthController, 'login'])
|
|
23
|
+
// .use(authLimiter)
|
|
24
|
+
router.post('logout', [AuthController, 'logout'])
|
|
25
|
+
// .use(middleware.auth())
|
|
26
|
+
router.post('activate', [AuthController, 'activate'])
|
|
27
|
+
router.post('activate/request', [AuthController, 'sendAccountActivationEmail'])
|
|
28
|
+
// .use(authLimiter)
|
|
29
|
+
router.post('password/forgot', [AuthController, 'requestPasswordReset'])
|
|
30
|
+
// .use(authLimiter)
|
|
31
|
+
router.post('password/validate', [AuthController, 'validatePasswordReset'])
|
|
32
|
+
router.post('password/reset', [AuthController, 'updatePassword'])
|
|
33
|
+
// .use(authLimiter)
|
|
34
|
+
})
|
|
35
|
+
.prefix('/api/v1/auth')
|
|
36
|
+
.as('adonia.auth')
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host example rate limiters.
|
|
3
|
+
*
|
|
4
|
+
* Peer: @adonisjs/limiter (not installed by `adonia add`).
|
|
5
|
+
* After `node ace add @adonisjs/limiter`, uncomment `.use(authLimiter)`
|
|
6
|
+
* on sensitive auth routes and `.use(accountLimiter)` on the account
|
|
7
|
+
* group in start/routes/account.ts.
|
|
8
|
+
*/
|
|
9
|
+
import limiter from '@adonisjs/limiter/services/main'
|
|
10
|
+
|
|
11
|
+
export const authLimiter = limiter.define('auth', () => {
|
|
12
|
+
return limiter.allowRequests(5).every('1 minute')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export const accountLimiter = limiter.define('account', () => {
|
|
16
|
+
return limiter.allowRequests(60).every('1 minute')
|
|
17
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import vine from '@vinejs/vine'
|
|
2
|
+
|
|
3
|
+
export const registerValidator = vine.compile(
|
|
4
|
+
vine.object({
|
|
5
|
+
email: vine.string().email().normalizeEmail().unique({ table: 'users', column: 'email' }),
|
|
6
|
+
password: vine.string().validPassword().confirmed(),
|
|
7
|
+
firstName: vine.string().trim().maxLength(80).optional(),
|
|
8
|
+
lastName: vine.string().trim().maxLength(80).optional(),
|
|
9
|
+
})
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
export const loginValidator = vine.compile(
|
|
13
|
+
vine.object({
|
|
14
|
+
email: vine.string().email().normalizeEmail(),
|
|
15
|
+
password: vine.string(),
|
|
16
|
+
})
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
export const activateValidator = vine.compile(
|
|
20
|
+
vine.object({
|
|
21
|
+
token: vine.string(),
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export const resendActivationValidator = vine.compile(
|
|
26
|
+
vine.object({
|
|
27
|
+
email: vine.string().email().normalizeEmail().exists({ table: 'users', column: 'email' }),
|
|
28
|
+
})
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
export const requestPasswordResetValidator = vine.compile(
|
|
32
|
+
vine.object({
|
|
33
|
+
email: vine.string().email().normalizeEmail().exists({ table: 'users', column: 'email' }),
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export const validatePasswordResetValidator = vine.compile(
|
|
38
|
+
vine.object({
|
|
39
|
+
token: vine.string().validToken({ table: 'password_resets', column: 'token' }),
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
export const updatePasswordValidator = vine.compile(
|
|
44
|
+
vine.object({
|
|
45
|
+
token: vine.string().validToken({ table: 'password_resets', column: 'token' }),
|
|
46
|
+
password: vine.string().validPassword().confirmed(),
|
|
47
|
+
})
|
|
48
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import vine from '@vinejs/vine'
|
|
2
|
+
import type { FieldContext } from '@vinejs/vine/types'
|
|
3
|
+
|
|
4
|
+
type Options = {
|
|
5
|
+
table: string
|
|
6
|
+
column: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function exists(value: unknown, options: Options, field: FieldContext) {
|
|
10
|
+
if (typeof value !== 'string') {
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const db = (await import('@adonisjs/lucid/services/db')).default
|
|
15
|
+
const row = await db.from(options.table).where(options.column, value).first()
|
|
16
|
+
if (!row) {
|
|
17
|
+
field.report('The selected {{ field }} is invalid', 'exists', field)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const existsRule = vine.createRule(exists)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import vine from '@vinejs/vine'
|
|
2
|
+
import type { FieldContext } from '@vinejs/vine/types'
|
|
3
|
+
|
|
4
|
+
type Options = {
|
|
5
|
+
table: string
|
|
6
|
+
column: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function unique(value: unknown, options: Options, field: FieldContext) {
|
|
10
|
+
if (typeof value !== 'string') {
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const db = (await import('@adonisjs/lucid/services/db')).default
|
|
15
|
+
const row = await db.from(options.table).where(options.column, value).first()
|
|
16
|
+
if (row) {
|
|
17
|
+
field.report('The {{ field }} has already been taken', 'unique', field)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const uniqueRule = vine.createRule(unique)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import vine from '@vinejs/vine'
|
|
2
|
+
import type { FieldContext } from '@vinejs/vine/types'
|
|
3
|
+
|
|
4
|
+
function validPassword(value: unknown, _options: undefined, field: FieldContext) {
|
|
5
|
+
if (typeof value !== 'string') {
|
|
6
|
+
return
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ok =
|
|
10
|
+
value.length >= 8 && /[A-Z]/.test(value) && /[a-z]/.test(value) && /[0-9]/.test(value)
|
|
11
|
+
if (!ok) {
|
|
12
|
+
field.report(
|
|
13
|
+
'The {{ field }} must be at least 8 characters and contain uppercase, lowercase, and a number',
|
|
14
|
+
'validPassword',
|
|
15
|
+
field
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const validPasswordRule = vine.createRule(validPassword)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import vine from '@vinejs/vine'
|
|
2
|
+
import type { FieldContext } from '@vinejs/vine/types'
|
|
3
|
+
|
|
4
|
+
type Options = {
|
|
5
|
+
table: string
|
|
6
|
+
column: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function validToken(value: unknown, options: Options, field: FieldContext) {
|
|
10
|
+
if (typeof value !== 'string') {
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const db = (await import('@adonisjs/lucid/services/db')).default
|
|
15
|
+
const row = await db.from(options.table).where(options.column, value).first()
|
|
16
|
+
if (!row) {
|
|
17
|
+
field.report('The {{ field }} is invalid', 'validToken', field)
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const expiresAt = (row as { expires_at?: string | Date | null }).expires_at
|
|
22
|
+
if (expiresAt && new Date(expiresAt).getTime() < Date.now()) {
|
|
23
|
+
field.report('The {{ field }} has expired', 'validToken', field)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const validTokenRule = vine.createRule(validToken)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare module '@vinejs/vine' {
|
|
2
|
+
interface VineString {
|
|
3
|
+
unique(options: { table: string; column: string }): this
|
|
4
|
+
exists(options: { table: string; column: string }): this
|
|
5
|
+
validToken(options: { table: string; column: string }): this
|
|
6
|
+
validPassword(): this
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export {}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { test } from '@japa/runner'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import encryption from '@adonisjs/core/services/encryption'
|
|
4
|
+
import { AuthService } from '#modules/auth/service'
|
|
5
|
+
import { MemoryUserStore } from '#modules/auth/tests/fakes/memory_user_store'
|
|
6
|
+
import { MemoryPasswordResetStore } from '#modules/auth/tests/fakes/memory_password_reset_store'
|
|
7
|
+
import { MemorySessionManager } from '#modules/auth/tests/fakes/memory_session_manager'
|
|
8
|
+
|
|
9
|
+
function makeAuth() {
|
|
10
|
+
const users = new MemoryUserStore()
|
|
11
|
+
const passwordResets = new MemoryPasswordResetStore(users)
|
|
12
|
+
const service = new AuthService(users, passwordResets)
|
|
13
|
+
const emissions: Array<{ event: string; payload: any }> = []
|
|
14
|
+
;(service as any).emitSafe = async (event: string, payload: any) => {
|
|
15
|
+
emissions.push({ event, payload })
|
|
16
|
+
}
|
|
17
|
+
return { service, users, passwordResets, emissions }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test.group('AuthService - register and activate', () => {
|
|
21
|
+
test('register issues a verification token', async () => {
|
|
22
|
+
const { service, users } = makeAuth()
|
|
23
|
+
const user = await service.registerUser({
|
|
24
|
+
email: 'a@test.com',
|
|
25
|
+
password: 'secret',
|
|
26
|
+
firstName: 'A',
|
|
27
|
+
})
|
|
28
|
+
assert.ok(user.emailVerificationToken)
|
|
29
|
+
assert.equal(users.users.get(user.id)?.emailVerificationToken, user.emailVerificationToken)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('activate with a valid token verifies the user', async () => {
|
|
33
|
+
const { service } = makeAuth()
|
|
34
|
+
const registered = await service.registerUser({
|
|
35
|
+
email: 'a@test.com',
|
|
36
|
+
password: 'secret',
|
|
37
|
+
})
|
|
38
|
+
const token = registered.emailVerificationToken!
|
|
39
|
+
const verified = await service.activateUserAccount(token)
|
|
40
|
+
assert.ok(verified.emailVerifiedAt)
|
|
41
|
+
assert.equal(verified.emailVerificationToken, null)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('activate with an expired token throws E_INVALID_TOKEN', async () => {
|
|
45
|
+
const { service, users } = makeAuth()
|
|
46
|
+
const registered = await service.registerUser({
|
|
47
|
+
email: 'a@test.com',
|
|
48
|
+
password: 'secret',
|
|
49
|
+
})
|
|
50
|
+
const expired = encryption.encrypt(registered.email, '-1 hours')
|
|
51
|
+
const stored = users.users.get(registered.id)!
|
|
52
|
+
stored.emailVerificationToken = expired
|
|
53
|
+
users.users.set(stored.id, stored)
|
|
54
|
+
|
|
55
|
+
await assert.rejects(
|
|
56
|
+
() => service.activateUserAccount(expired),
|
|
57
|
+
(err: any) => {
|
|
58
|
+
assert.equal(err.code, 'E_INVALID_TOKEN')
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test.group('AuthService - loginByEmail', () => {
|
|
66
|
+
test('login for an unverified user throws and has no side effects', async () => {
|
|
67
|
+
const { service, users, emissions } = makeAuth()
|
|
68
|
+
const registered = await service.registerUser({
|
|
69
|
+
email: 'a@test.com',
|
|
70
|
+
password: 'secret',
|
|
71
|
+
})
|
|
72
|
+
const savesBefore = users.saves.length
|
|
73
|
+
emissions.length = 0
|
|
74
|
+
|
|
75
|
+
const session = new MemorySessionManager()
|
|
76
|
+
await assert.rejects(
|
|
77
|
+
() => service.loginByEmail({ email: 'a@test.com', password: 'secret' }, session),
|
|
78
|
+
(err: any) => {
|
|
79
|
+
assert.equal(err.code, 'E_ACCOUNT_UNVERIFIED')
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assert.equal(users.saves.length, savesBefore)
|
|
85
|
+
assert.equal(emissions.length, 0)
|
|
86
|
+
assert.equal(session.logins.length, 0)
|
|
87
|
+
assert.equal(registered.emailVerifiedAt, null)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('login returns user and session result', async () => {
|
|
91
|
+
const { service } = makeAuth()
|
|
92
|
+
const registered = await service.registerUser({
|
|
93
|
+
email: 'a@test.com',
|
|
94
|
+
password: 'secret',
|
|
95
|
+
})
|
|
96
|
+
await service.activateUserAccount(registered.emailVerificationToken!)
|
|
97
|
+
|
|
98
|
+
const session = new MemorySessionManager()
|
|
99
|
+
session.result = { token: 'tok', expiresAt: new Date('2030-01-01') }
|
|
100
|
+
const result = await service.loginByEmail({ email: 'a@test.com', password: 'secret' }, session)
|
|
101
|
+
|
|
102
|
+
assert.equal(result.user.email, 'a@test.com')
|
|
103
|
+
assert.equal(result.session.token, 'tok')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test.group('AuthService - password reset', () => {
|
|
108
|
+
test('request creates one row and deletes older rows', async () => {
|
|
109
|
+
const { service, users, passwordResets } = makeAuth()
|
|
110
|
+
const user = await users.createEmailUser({ email: 'a@test.com', password: 'secret' })
|
|
111
|
+
await service.requestPasswordReset('a@test.com')
|
|
112
|
+
await service.requestPasswordReset('a@test.com')
|
|
113
|
+
assert.equal(passwordResets.rows.size, 1)
|
|
114
|
+
assert.equal([...passwordResets.rows.values()][0].userId, user.id)
|
|
115
|
+
assert.equal(passwordResets.createCalls.length, 2)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('validate rotates the token', async () => {
|
|
119
|
+
const { service, users, passwordResets } = makeAuth()
|
|
120
|
+
await users.createEmailUser({ email: 'a@test.com', password: 'secret' })
|
|
121
|
+
await service.requestPasswordReset('a@test.com')
|
|
122
|
+
const original = [...passwordResets.byToken.keys()][0]
|
|
123
|
+
const refreshed = await service.validatePasswordResetToken(original)
|
|
124
|
+
assert.notEqual(refreshed.token, original)
|
|
125
|
+
assert.equal(await passwordResets.findByToken(original), null)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('update succeeds once and the token is dead afterward', async () => {
|
|
129
|
+
const { service, users, passwordResets } = makeAuth()
|
|
130
|
+
await users.createEmailUser({ email: 'a@test.com', password: 'secret' })
|
|
131
|
+
await service.requestPasswordReset('a@test.com')
|
|
132
|
+
const token = [...passwordResets.byToken.keys()][0]
|
|
133
|
+
|
|
134
|
+
await service.updatePassword({ token, password: 'new-pass' })
|
|
135
|
+
assert.equal(await users.verifyPassword(1, 'new-pass'), true)
|
|
136
|
+
|
|
137
|
+
await assert.rejects(
|
|
138
|
+
() => service.updatePassword({ token, password: 'another' }),
|
|
139
|
+
(err: any) => {
|
|
140
|
+
assert.equal(err.code, 'E_INVALID_TOKEN')
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { test } from '@japa/runner'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { AuthService } from '#modules/auth/service'
|
|
4
|
+
import type { User, UserStore } from '#modules/contracts/index'
|
|
5
|
+
import type {
|
|
6
|
+
AuthPasswordReset,
|
|
7
|
+
PasswordResetStore,
|
|
8
|
+
TokenDuration,
|
|
9
|
+
} from '#modules/auth/contracts/index'
|
|
10
|
+
|
|
11
|
+
function makeUser(overrides: Partial<User> = {}): User {
|
|
12
|
+
return {
|
|
13
|
+
id: 1,
|
|
14
|
+
email: 'user@test.com',
|
|
15
|
+
pendingEmail: null,
|
|
16
|
+
authMethod: 'email',
|
|
17
|
+
firstName: 'Test',
|
|
18
|
+
lastName: 'User',
|
|
19
|
+
avatarKey: null,
|
|
20
|
+
emailVerifiedAt: new Date(),
|
|
21
|
+
emailVerificationToken: null,
|
|
22
|
+
...overrides,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeUserStore(overrides: Partial<UserStore> = {}): UserStore {
|
|
27
|
+
return {
|
|
28
|
+
createEmailUser: async () => makeUser(),
|
|
29
|
+
findOrFail: async () => makeUser(),
|
|
30
|
+
findByEmail: async () => null,
|
|
31
|
+
findByEmailOrFail: async () => makeUser(),
|
|
32
|
+
findByVerificationToken: async () => null,
|
|
33
|
+
verifyCredentials: async () => makeUser(),
|
|
34
|
+
verifyPassword: async () => true,
|
|
35
|
+
save: async (u) => u,
|
|
36
|
+
updatePassword: async (u) => u,
|
|
37
|
+
delete: async () => {},
|
|
38
|
+
...overrides,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makePasswordResetStore(seed?: AuthPasswordReset): PasswordResetStore {
|
|
43
|
+
const tokens = new Map<string, AuthPasswordReset>()
|
|
44
|
+
if (seed) {
|
|
45
|
+
tokens.set(seed.token, seed)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
async createForUser(userId: number, _duration: TokenDuration) {
|
|
50
|
+
const row: AuthPasswordReset = {
|
|
51
|
+
id: 1,
|
|
52
|
+
userId,
|
|
53
|
+
token: 'fresh',
|
|
54
|
+
isExpired: false,
|
|
55
|
+
}
|
|
56
|
+
tokens.set(row.token, row)
|
|
57
|
+
return row
|
|
58
|
+
},
|
|
59
|
+
async findByToken(token: string) {
|
|
60
|
+
return tokens.get(token) ?? null
|
|
61
|
+
},
|
|
62
|
+
async deleteForUser(userId: number) {
|
|
63
|
+
for (const [key, row] of tokens) {
|
|
64
|
+
if (row.userId === userId) {
|
|
65
|
+
tokens.delete(key)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async refreshToken(reset: AuthPasswordReset, _duration: TokenDuration) {
|
|
70
|
+
return reset
|
|
71
|
+
},
|
|
72
|
+
async findUserByReset(reset: AuthPasswordReset) {
|
|
73
|
+
return makeUser({ id: reset.userId })
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test.group('AuthService - updatePassword token reuse', () => {
|
|
79
|
+
test('second updatePassword with same token throws E_INVALID_TOKEN', async () => {
|
|
80
|
+
const reset: AuthPasswordReset = {
|
|
81
|
+
id: 1,
|
|
82
|
+
userId: 1,
|
|
83
|
+
token: 'reuse-me',
|
|
84
|
+
isExpired: false,
|
|
85
|
+
}
|
|
86
|
+
const passwordResets = makePasswordResetStore(reset)
|
|
87
|
+
const service = new AuthService(makeUserStore(), passwordResets)
|
|
88
|
+
|
|
89
|
+
await service.updatePassword({ token: 'reuse-me', password: 'new-pass-1' })
|
|
90
|
+
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
() => service.updatePassword({ token: 'reuse-me', password: 'new-pass-2' }),
|
|
93
|
+
(err: any) => {
|
|
94
|
+
assert.equal(err.code, 'E_INVALID_TOKEN')
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { User } from '#modules/contracts/index'
|
|
2
|
+
import type {
|
|
3
|
+
AuthPasswordReset,
|
|
4
|
+
PasswordResetStore,
|
|
5
|
+
TokenDuration,
|
|
6
|
+
} from '#modules/auth/contracts/index'
|
|
7
|
+
import type { MemoryUserStore } from './memory_user_store.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* In-memory PasswordResetStore. createForUser deletes prior rows for the user.
|
|
11
|
+
*/
|
|
12
|
+
export class MemoryPasswordResetStore implements PasswordResetStore {
|
|
13
|
+
rows = new Map<number, AuthPasswordReset>()
|
|
14
|
+
byToken = new Map<string, AuthPasswordReset>()
|
|
15
|
+
nextId = 1
|
|
16
|
+
createCalls: number[] = []
|
|
17
|
+
|
|
18
|
+
constructor(private users?: MemoryUserStore) {}
|
|
19
|
+
|
|
20
|
+
async createForUser(userId: number, _duration: TokenDuration): Promise<AuthPasswordReset> {
|
|
21
|
+
this.createCalls.push(userId)
|
|
22
|
+
await this.deleteForUser(userId)
|
|
23
|
+
const row: AuthPasswordReset = {
|
|
24
|
+
id: this.nextId++,
|
|
25
|
+
userId,
|
|
26
|
+
token: `reset-${userId}-${this.nextId}`,
|
|
27
|
+
isExpired: false,
|
|
28
|
+
}
|
|
29
|
+
this.rows.set(row.id, row)
|
|
30
|
+
this.byToken.set(row.token, row)
|
|
31
|
+
return { ...row }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async findByToken(token: string): Promise<AuthPasswordReset | null> {
|
|
35
|
+
const row = this.byToken.get(token)
|
|
36
|
+
return row ? { ...row } : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async deleteForUser(userId: number): Promise<void> {
|
|
40
|
+
for (const [id, row] of this.rows) {
|
|
41
|
+
if (row.userId === userId) {
|
|
42
|
+
this.rows.delete(id)
|
|
43
|
+
this.byToken.delete(row.token)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async refreshToken(
|
|
49
|
+
reset: AuthPasswordReset,
|
|
50
|
+
_duration: TokenDuration
|
|
51
|
+
): Promise<AuthPasswordReset> {
|
|
52
|
+
const existing = this.rows.get(reset.id)
|
|
53
|
+
if (!existing) throw new Error('reset not found')
|
|
54
|
+
this.byToken.delete(existing.token)
|
|
55
|
+
const refreshed: AuthPasswordReset = {
|
|
56
|
+
...existing,
|
|
57
|
+
token: `refreshed-${existing.id}`,
|
|
58
|
+
isExpired: false,
|
|
59
|
+
}
|
|
60
|
+
this.rows.set(refreshed.id, refreshed)
|
|
61
|
+
this.byToken.set(refreshed.token, refreshed)
|
|
62
|
+
return { ...refreshed }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async findUserByReset(reset: AuthPasswordReset): Promise<User> {
|
|
66
|
+
if (!this.users) {
|
|
67
|
+
throw new Error('MemoryPasswordResetStore requires a MemoryUserStore for findUserByReset')
|
|
68
|
+
}
|
|
69
|
+
return this.users.findOrFail(reset.userId)
|
|
70
|
+
}
|
|
71
|
+
}
|