@stravigor/bastion 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +19 -0
- package/src/actions.ts +24 -0
- package/src/bastion_manager.ts +209 -0
- package/src/bastion_provider.ts +25 -0
- package/src/errors.ts +21 -0
- package/src/handlers/confirm_password.ts +32 -0
- package/src/handlers/forgot_password.ts +36 -0
- package/src/handlers/login.ts +65 -0
- package/src/handlers/logout.ts +23 -0
- package/src/handlers/register.ts +70 -0
- package/src/handlers/reset_password.ts +56 -0
- package/src/handlers/two_factor.ts +172 -0
- package/src/handlers/update_password.ts +40 -0
- package/src/handlers/update_profile.ts +17 -0
- package/src/handlers/verify_email.ts +83 -0
- package/src/helpers.ts +70 -0
- package/src/index.ts +51 -0
- package/src/middleware/confirmed.ts +28 -0
- package/src/middleware/two_factor_challenge.ts +32 -0
- package/src/middleware/verified.ts +24 -0
- package/src/tokens.ts +60 -0
- package/src/totp.ts +174 -0
- package/src/types.ts +135 -0
- package/stubs/actions/bastion.ts +83 -0
- package/stubs/config/bastion.ts +55 -0
- package/stubs/emails/reset-password.strav +26 -0
- package/stubs/emails/verify-email.strav +26 -0
- package/tsconfig.json +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravigor/bastion",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Headless authentication flows for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": ["src/", "stubs/", "package.json", "tsconfig.json"],
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@stravigor/core": "0.3.3"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "bun test tests/",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BastionActions } from './types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type-safe identity function for defining Bastion actions.
|
|
5
|
+
* Zero runtime cost — just provides autocompletion and type checking.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { defineActions } from '@stravigor/bastion'
|
|
9
|
+
* import { User } from '../models/user'
|
|
10
|
+
*
|
|
11
|
+
* export default defineActions<User>({
|
|
12
|
+
* createUser: async (data) => User.create({ ... }),
|
|
13
|
+
* findByEmail: (email) => User.query().where('email', email).first(),
|
|
14
|
+
* findById: (id) => User.find(id),
|
|
15
|
+
* passwordHashOf: (user) => user.password,
|
|
16
|
+
* emailOf: (user) => user.email,
|
|
17
|
+
* updatePassword: async (user, pw) => { user.password = await encrypt.hash(pw); await user.save() },
|
|
18
|
+
* })
|
|
19
|
+
*/
|
|
20
|
+
export function defineActions<TUser = unknown>(
|
|
21
|
+
actions: BastionActions<TUser>
|
|
22
|
+
): BastionActions<TUser> {
|
|
23
|
+
return actions
|
|
24
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { inject } from '@stravigor/core/core'
|
|
2
|
+
import type Configuration from '@stravigor/core/config/configuration'
|
|
3
|
+
import type Router from '@stravigor/core/http/router'
|
|
4
|
+
import { compose } from '@stravigor/core/http/middleware'
|
|
5
|
+
import type { Handler, Middleware } from '@stravigor/core/http/middleware'
|
|
6
|
+
import { rateLimit } from '@stravigor/core/http/rate_limit'
|
|
7
|
+
import { auth } from '@stravigor/core/auth/middleware/authenticate'
|
|
8
|
+
import { guest } from '@stravigor/core/auth/middleware/guest'
|
|
9
|
+
import { ConfigurationError } from '@stravigor/core/exceptions/errors'
|
|
10
|
+
import { MissingActionError } from './errors.ts'
|
|
11
|
+
import type { BastionActions, BastionConfig, Feature } from './types.ts'
|
|
12
|
+
import { registerHandler } from './handlers/register.ts'
|
|
13
|
+
import { loginHandler } from './handlers/login.ts'
|
|
14
|
+
import { logoutHandler } from './handlers/logout.ts'
|
|
15
|
+
import { forgotPasswordHandler } from './handlers/forgot_password.ts'
|
|
16
|
+
import { resetPasswordHandler } from './handlers/reset_password.ts'
|
|
17
|
+
import { sendVerificationHandler, verifyEmailHandler } from './handlers/verify_email.ts'
|
|
18
|
+
import {
|
|
19
|
+
enableTwoFactorHandler,
|
|
20
|
+
confirmTwoFactorHandler,
|
|
21
|
+
disableTwoFactorHandler,
|
|
22
|
+
twoFactorChallengeHandler,
|
|
23
|
+
} from './handlers/two_factor.ts'
|
|
24
|
+
import { confirmPasswordHandler } from './handlers/confirm_password.ts'
|
|
25
|
+
import { updatePasswordHandler } from './handlers/update_password.ts'
|
|
26
|
+
import { updateProfileHandler } from './handlers/update_profile.ts'
|
|
27
|
+
import { confirmed } from './middleware/confirmed.ts'
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Default config
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const DEFAULTS: BastionConfig = {
|
|
34
|
+
features: ['registration', 'login', 'logout', 'password-reset'],
|
|
35
|
+
prefix: '',
|
|
36
|
+
mode: 'session',
|
|
37
|
+
rateLimit: {
|
|
38
|
+
login: { max: 5, window: 60 },
|
|
39
|
+
register: { max: 3, window: 60 },
|
|
40
|
+
forgotPassword: { max: 3, window: 60 },
|
|
41
|
+
verifyEmail: { max: 3, window: 60 },
|
|
42
|
+
twoFactor: { max: 5, window: 60 },
|
|
43
|
+
},
|
|
44
|
+
passwords: { expiration: 60 },
|
|
45
|
+
verification: { expiration: 60 },
|
|
46
|
+
confirmation: { timeout: 10_800 },
|
|
47
|
+
twoFactor: { issuer: 'Strav', digits: 6, period: 30, recoveryCodes: 8 },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Manager
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/** Wrap a handler with middleware via compose. */
|
|
55
|
+
function withMiddleware(mw: Middleware[], handler: Handler): Handler {
|
|
56
|
+
return mw.length > 0 ? compose(mw, handler) : handler
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@inject
|
|
60
|
+
export default class BastionManager {
|
|
61
|
+
private static _config: BastionConfig
|
|
62
|
+
private static _actions: BastionActions
|
|
63
|
+
|
|
64
|
+
constructor(config: Configuration) {
|
|
65
|
+
const raw = config.get('bastion', {}) as Partial<BastionConfig>
|
|
66
|
+
BastionManager._config = { ...DEFAULTS, ...raw } as BastionConfig
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Accessors ────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
static get config(): BastionConfig {
|
|
72
|
+
if (!BastionManager._config) {
|
|
73
|
+
throw new ConfigurationError('BastionManager not configured. Resolve it through the container first.')
|
|
74
|
+
}
|
|
75
|
+
return BastionManager._config
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static get actions(): BastionActions {
|
|
79
|
+
if (!BastionManager._actions) {
|
|
80
|
+
throw new ConfigurationError('Bastion actions not set. Pass actions to BastionProvider.')
|
|
81
|
+
}
|
|
82
|
+
return BastionManager._actions
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Set the user-defined actions contract. */
|
|
86
|
+
static useActions(actions: BastionActions): void {
|
|
87
|
+
BastionManager._actions = actions
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Check whether a feature is enabled. */
|
|
91
|
+
static hasFeature(feature: Feature): boolean {
|
|
92
|
+
return BastionManager._config.features.includes(feature)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Validation ───────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** Verify that all required actions are provided for enabled features. */
|
|
98
|
+
static validateActions(): void {
|
|
99
|
+
const a = BastionManager._actions
|
|
100
|
+
const has = (f: Feature) => BastionManager.hasFeature(f)
|
|
101
|
+
|
|
102
|
+
if (has('email-verification')) {
|
|
103
|
+
if (!a.isEmailVerified) throw new MissingActionError('isEmailVerified', 'email-verification')
|
|
104
|
+
if (!a.markEmailVerified) throw new MissingActionError('markEmailVerified', 'email-verification')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (has('two-factor')) {
|
|
108
|
+
if (!a.twoFactorSecretOf) throw new MissingActionError('twoFactorSecretOf', 'two-factor')
|
|
109
|
+
if (!a.setTwoFactorSecret) throw new MissingActionError('setTwoFactorSecret', 'two-factor')
|
|
110
|
+
if (!a.recoveryCodesOf) throw new MissingActionError('recoveryCodesOf', 'two-factor')
|
|
111
|
+
if (!a.setRecoveryCodes) throw new MissingActionError('setRecoveryCodes', 'two-factor')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (has('update-profile')) {
|
|
115
|
+
if (!a.updateProfile) throw new MissingActionError('updateProfile', 'update-profile')
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Route registration ───────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/** Build a rate limit middleware from a config key. */
|
|
122
|
+
private static rl(key: keyof BastionConfig['rateLimit']): Middleware {
|
|
123
|
+
const cfg = BastionManager._config.rateLimit[key]
|
|
124
|
+
return rateLimit({ max: cfg.max, window: cfg.window * 1000 })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Register all Bastion routes on the given router.
|
|
129
|
+
*
|
|
130
|
+
* @param router - The router instance.
|
|
131
|
+
* @param options - `only` or `except` to selectively register routes.
|
|
132
|
+
*/
|
|
133
|
+
static routes(
|
|
134
|
+
router: Router,
|
|
135
|
+
options?: { only?: Feature[]; except?: Feature[] }
|
|
136
|
+
): void {
|
|
137
|
+
const enabled = (f: Feature): boolean => {
|
|
138
|
+
if (!BastionManager.hasFeature(f)) return false
|
|
139
|
+
if (options?.only) return options.only.includes(f)
|
|
140
|
+
if (options?.except) return !options.except.includes(f)
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const prefix = BastionManager._config.prefix
|
|
145
|
+
|
|
146
|
+
router.group({ prefix }, r => {
|
|
147
|
+
if (enabled('registration')) {
|
|
148
|
+
r.post('/register', withMiddleware(
|
|
149
|
+
[guest(), BastionManager.rl('register')], registerHandler
|
|
150
|
+
))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (enabled('login')) {
|
|
154
|
+
r.post('/login', withMiddleware(
|
|
155
|
+
[guest(), BastionManager.rl('login')], loginHandler
|
|
156
|
+
))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (enabled('logout')) {
|
|
160
|
+
r.post('/logout', withMiddleware([auth()], logoutHandler))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (enabled('password-reset')) {
|
|
164
|
+
r.post('/forgot-password', withMiddleware(
|
|
165
|
+
[guest(), BastionManager.rl('forgotPassword')], forgotPasswordHandler
|
|
166
|
+
))
|
|
167
|
+
r.post('/reset-password', withMiddleware([guest()], resetPasswordHandler))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (enabled('email-verification')) {
|
|
171
|
+
r.post('/email/send', withMiddleware(
|
|
172
|
+
[auth(), BastionManager.rl('verifyEmail')], sendVerificationHandler
|
|
173
|
+
))
|
|
174
|
+
r.get('/email/verify/:token', verifyEmailHandler)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (enabled('two-factor')) {
|
|
178
|
+
r.post('/two-factor/enable', withMiddleware(
|
|
179
|
+
[auth(), confirmed()], enableTwoFactorHandler
|
|
180
|
+
))
|
|
181
|
+
r.post('/two-factor/confirm', withMiddleware([auth()], confirmTwoFactorHandler))
|
|
182
|
+
r.delete('/two-factor', withMiddleware(
|
|
183
|
+
[auth(), confirmed()], disableTwoFactorHandler
|
|
184
|
+
))
|
|
185
|
+
r.post('/two-factor/challenge', withMiddleware(
|
|
186
|
+
[BastionManager.rl('twoFactor')], twoFactorChallengeHandler
|
|
187
|
+
))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (enabled('password-confirmation')) {
|
|
191
|
+
r.post('/confirm-password', withMiddleware([auth()], confirmPasswordHandler))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (enabled('update-password')) {
|
|
195
|
+
r.put('/password', withMiddleware([auth()], updatePasswordHandler))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (enabled('update-profile')) {
|
|
199
|
+
r.put('/profile', withMiddleware([auth()], updateProfileHandler))
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Clear all state. For testing. */
|
|
205
|
+
static reset(): void {
|
|
206
|
+
BastionManager._config = undefined as any
|
|
207
|
+
BastionManager._actions = undefined as any
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ServiceProvider } from '@stravigor/core/core'
|
|
2
|
+
import type { Application } from '@stravigor/core/core'
|
|
3
|
+
import Router from '@stravigor/core/http/router'
|
|
4
|
+
import BastionManager from './bastion_manager.ts'
|
|
5
|
+
import type { BastionActions } from './types.ts'
|
|
6
|
+
|
|
7
|
+
export default class BastionProvider extends ServiceProvider {
|
|
8
|
+
readonly name = 'bastion'
|
|
9
|
+
override readonly dependencies = ['auth', 'session', 'encryption', 'mail']
|
|
10
|
+
|
|
11
|
+
constructor(private actions: BastionActions) {
|
|
12
|
+
super()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
override register(app: Application): void {
|
|
16
|
+
app.singleton(BastionManager)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override boot(app: Application): void {
|
|
20
|
+
app.resolve(BastionManager)
|
|
21
|
+
BastionManager.useActions(this.actions)
|
|
22
|
+
BastionManager.validateActions()
|
|
23
|
+
BastionManager.routes(app.resolve(Router))
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { StravError } from '@stravigor/core/exceptions/strav_error'
|
|
2
|
+
|
|
3
|
+
/** Base error for all Bastion errors. */
|
|
4
|
+
export class BastionError extends StravError {}
|
|
5
|
+
|
|
6
|
+
/** Thrown when a required action is missing for an enabled feature. */
|
|
7
|
+
export class MissingActionError extends BastionError {
|
|
8
|
+
constructor(action: string, feature: string) {
|
|
9
|
+
super(`Bastion action "${action}" is required when the "${feature}" feature is enabled.`)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Thrown when input validation fails. */
|
|
14
|
+
export class ValidationError extends BastionError {
|
|
15
|
+
constructor(
|
|
16
|
+
message: string,
|
|
17
|
+
public readonly errors: Record<string, string> = {}
|
|
18
|
+
) {
|
|
19
|
+
super(message)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import type Session from '@stravigor/core/session/session'
|
|
3
|
+
import { encrypt } from '@stravigor/core/encryption'
|
|
4
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
5
|
+
import BastionManager from '../bastion_manager.ts'
|
|
6
|
+
import { BastionEvents } from '../types.ts'
|
|
7
|
+
|
|
8
|
+
export async function confirmPasswordHandler(ctx: Context): Promise<Response> {
|
|
9
|
+
const body = await ctx.body<{ password?: string }>()
|
|
10
|
+
|
|
11
|
+
if (!body.password) {
|
|
12
|
+
return ctx.json({ message: 'Password is required.' }, 422)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const user = ctx.get('user')
|
|
16
|
+
const hash = BastionManager.actions.passwordHashOf(user)
|
|
17
|
+
const valid = await encrypt.verify(body.password, hash)
|
|
18
|
+
|
|
19
|
+
if (!valid) {
|
|
20
|
+
return ctx.json({ message: 'Invalid password.' }, 422)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Store confirmation timestamp in session
|
|
24
|
+
const session = ctx.get<Session>('session')
|
|
25
|
+
session.set('_bastion_confirmed_at', Date.now())
|
|
26
|
+
|
|
27
|
+
if (Emitter.listenerCount(BastionEvents.PASSWORD_CONFIRMED) > 0) {
|
|
28
|
+
Emitter.emit(BastionEvents.PASSWORD_CONFIRMED, { user, ctx }).catch(() => {})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return ctx.json({ message: 'Password confirmed.' })
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import { mail } from '@stravigor/core/mail'
|
|
3
|
+
import { extractUserId } from '@stravigor/core/helpers/identity'
|
|
4
|
+
import BastionManager from '../bastion_manager.ts'
|
|
5
|
+
import { createSignedToken } from '../tokens.ts'
|
|
6
|
+
|
|
7
|
+
export async function forgotPasswordHandler(ctx: Context): Promise<Response> {
|
|
8
|
+
const body = await ctx.body<{ email?: string }>()
|
|
9
|
+
|
|
10
|
+
if (!body.email) {
|
|
11
|
+
return ctx.json({ message: 'Email is required.' }, 422)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Always return success to prevent email enumeration
|
|
15
|
+
const user = await BastionManager.actions.findByEmail(body.email)
|
|
16
|
+
|
|
17
|
+
if (user) {
|
|
18
|
+
const config = BastionManager.config
|
|
19
|
+
const userId = extractUserId(user)
|
|
20
|
+
const email = BastionManager.actions.emailOf(user)
|
|
21
|
+
|
|
22
|
+
const token = createSignedToken(
|
|
23
|
+
{ sub: userId, typ: 'password-reset', email },
|
|
24
|
+
config.passwords.expiration
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const resetUrl = `${ctx.url.origin}${config.prefix}/reset-password?token=${encodeURIComponent(token)}`
|
|
28
|
+
|
|
29
|
+
await mail.to(email)
|
|
30
|
+
.subject('Reset Your Password')
|
|
31
|
+
.template('bastion.reset-password', { resetUrl, expiration: config.passwords.expiration })
|
|
32
|
+
.send()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return ctx.json({ message: 'If an account exists, a reset link has been sent.' })
|
|
36
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import type Session from '@stravigor/core/session/session'
|
|
3
|
+
import AccessToken from '@stravigor/core/auth/access_token'
|
|
4
|
+
import { encrypt } from '@stravigor/core/encryption'
|
|
5
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
6
|
+
import BastionManager from '../bastion_manager.ts'
|
|
7
|
+
import { BastionEvents } from '../types.ts'
|
|
8
|
+
|
|
9
|
+
export async function loginHandler(ctx: Context): Promise<Response> {
|
|
10
|
+
const body = await ctx.body<{ email?: string; password?: string }>()
|
|
11
|
+
const { email, password } = body
|
|
12
|
+
|
|
13
|
+
// Validate
|
|
14
|
+
if (!email || !password) {
|
|
15
|
+
return ctx.json({ message: 'Email and password are required.' }, 422)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Find user
|
|
19
|
+
const user = await BastionManager.actions.findByEmail(email)
|
|
20
|
+
if (!user) {
|
|
21
|
+
return ctx.json({ message: 'Invalid credentials.' }, 401)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Verify password
|
|
25
|
+
const hash = BastionManager.actions.passwordHashOf(user)
|
|
26
|
+
const valid = await encrypt.verify(password, hash)
|
|
27
|
+
if (!valid) {
|
|
28
|
+
return ctx.json({ message: 'Invalid credentials.' }, 401)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Two-factor challenge
|
|
32
|
+
if (BastionManager.hasFeature('two-factor') && BastionManager.actions.twoFactorSecretOf) {
|
|
33
|
+
const secret = BastionManager.actions.twoFactorSecretOf(user)
|
|
34
|
+
if (secret) {
|
|
35
|
+
// Store the user email in session for the challenge step
|
|
36
|
+
const session = ctx.get<Session>('session')
|
|
37
|
+
session.set('_bastion_2fa_email', email)
|
|
38
|
+
return ctx.json({ two_factor: true })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return completeLogin(ctx, user)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Finalize login — authenticate session or issue token. */
|
|
46
|
+
export async function completeLogin(ctx: Context, user: unknown): Promise<Response> {
|
|
47
|
+
const config = BastionManager.config
|
|
48
|
+
|
|
49
|
+
if (config.mode === 'session') {
|
|
50
|
+
const session = ctx.get<Session>('session')
|
|
51
|
+
session.authenticate(user)
|
|
52
|
+
await session.regenerate()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Emitter.listenerCount(BastionEvents.LOGIN) > 0) {
|
|
56
|
+
Emitter.emit(BastionEvents.LOGIN, { user, ctx }).catch(() => {})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (config.mode === 'token') {
|
|
60
|
+
const { token, accessToken } = await AccessToken.create(user, 'login')
|
|
61
|
+
return ctx.json({ user, token, accessToken })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return ctx.json({ user })
|
|
65
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import Session from '@stravigor/core/session/session'
|
|
3
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
4
|
+
import BastionManager from '../bastion_manager.ts'
|
|
5
|
+
import { BastionEvents } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
export async function logoutHandler(ctx: Context): Promise<Response> {
|
|
8
|
+
const user = ctx.get('user')
|
|
9
|
+
|
|
10
|
+
if (Emitter.listenerCount(BastionEvents.LOGOUT) > 0) {
|
|
11
|
+
Emitter.emit(BastionEvents.LOGOUT, { user, ctx }).catch(() => {})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (BastionManager.config.mode === 'session') {
|
|
15
|
+
const response = ctx.json({ message: 'Logged out.' })
|
|
16
|
+
return Session.destroy(ctx, response)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Token mode: the client should discard the token.
|
|
20
|
+
// Optionally we could revoke the token here, but the auth middleware
|
|
21
|
+
// already attaches the accessToken to the context.
|
|
22
|
+
return ctx.json({ message: 'Logged out.' })
|
|
23
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import type Session from '@stravigor/core/session/session'
|
|
3
|
+
import AccessToken from '@stravigor/core/auth/access_token'
|
|
4
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
5
|
+
import BastionManager from '../bastion_manager.ts'
|
|
6
|
+
import { ValidationError } from '../errors.ts'
|
|
7
|
+
import { BastionEvents } from '../types.ts'
|
|
8
|
+
import { sendVerificationEmail } from './verify_email.ts'
|
|
9
|
+
|
|
10
|
+
export async function registerHandler(ctx: Context): Promise<Response> {
|
|
11
|
+
const body = await ctx.body<Record<string, unknown>>()
|
|
12
|
+
const { name, email, password, password_confirmation } = body as {
|
|
13
|
+
name?: string
|
|
14
|
+
email?: string
|
|
15
|
+
password?: string
|
|
16
|
+
password_confirmation?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Validate
|
|
20
|
+
const errors: Record<string, string> = {}
|
|
21
|
+
if (!name || typeof name !== 'string') errors.name = 'Name is required.'
|
|
22
|
+
if (!email || typeof email !== 'string') errors.email = 'Email is required.'
|
|
23
|
+
if (!password || typeof password !== 'string') errors.password = 'Password is required.'
|
|
24
|
+
else if (password.length < 8) errors.password = 'Password must be at least 8 characters.'
|
|
25
|
+
if (password !== password_confirmation) errors.password_confirmation = 'Passwords do not match.'
|
|
26
|
+
|
|
27
|
+
if (Object.keys(errors).length > 0) {
|
|
28
|
+
return ctx.json({ message: 'Validation failed.', errors }, 422)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check if email is already taken
|
|
32
|
+
const existing = await BastionManager.actions.findByEmail(email!)
|
|
33
|
+
if (existing) {
|
|
34
|
+
return ctx.json({ message: 'Validation failed.', errors: { email: 'Email already taken.' } }, 422)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Create user
|
|
38
|
+
const user = await BastionManager.actions.createUser({
|
|
39
|
+
name: name!,
|
|
40
|
+
email: email!,
|
|
41
|
+
password: password!,
|
|
42
|
+
...body,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const config = BastionManager.config
|
|
46
|
+
|
|
47
|
+
// Authenticate
|
|
48
|
+
if (config.mode === 'session') {
|
|
49
|
+
const session = ctx.get<Session>('session')
|
|
50
|
+
session.authenticate(user)
|
|
51
|
+
await session.regenerate()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Emit
|
|
55
|
+
if (Emitter.listenerCount(BastionEvents.REGISTERED) > 0) {
|
|
56
|
+
Emitter.emit(BastionEvents.REGISTERED, { user, ctx }).catch(() => {})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Auto-send verification email if feature enabled
|
|
60
|
+
if (BastionManager.hasFeature('email-verification')) {
|
|
61
|
+
sendVerificationEmail(user).catch(() => {})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (config.mode === 'token') {
|
|
65
|
+
const { token, accessToken } = await AccessToken.create(user, 'registration')
|
|
66
|
+
return ctx.json({ user, token, accessToken }, 201)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return ctx.json({ user }, 201)
|
|
70
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
3
|
+
import BastionManager from '../bastion_manager.ts'
|
|
4
|
+
import { verifySignedToken } from '../tokens.ts'
|
|
5
|
+
import { BastionEvents } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
export async function resetPasswordHandler(ctx: Context): Promise<Response> {
|
|
8
|
+
const body = await ctx.body<{
|
|
9
|
+
token?: string
|
|
10
|
+
password?: string
|
|
11
|
+
password_confirmation?: string
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
// Validate input
|
|
15
|
+
if (!body.token) {
|
|
16
|
+
return ctx.json({ message: 'Token is required.' }, 422)
|
|
17
|
+
}
|
|
18
|
+
if (!body.password || body.password.length < 8) {
|
|
19
|
+
return ctx.json({ message: 'Password must be at least 8 characters.' }, 422)
|
|
20
|
+
}
|
|
21
|
+
if (body.password !== body.password_confirmation) {
|
|
22
|
+
return ctx.json({ message: 'Passwords do not match.' }, 422)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Verify token
|
|
26
|
+
let payload: { sub: string | number; typ: string; email: string }
|
|
27
|
+
try {
|
|
28
|
+
payload = verifySignedToken(body.token)
|
|
29
|
+
} catch {
|
|
30
|
+
return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (payload.typ !== 'password-reset') {
|
|
34
|
+
return ctx.json({ message: 'Invalid token type.' }, 422)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Find user
|
|
38
|
+
const user = await BastionManager.actions.findById(payload.sub)
|
|
39
|
+
if (!user) {
|
|
40
|
+
return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Verify the email still matches (prevents token reuse after email change)
|
|
44
|
+
if (BastionManager.actions.emailOf(user) !== payload.email) {
|
|
45
|
+
return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Update password
|
|
49
|
+
await BastionManager.actions.updatePassword(user, body.password)
|
|
50
|
+
|
|
51
|
+
if (Emitter.listenerCount(BastionEvents.PASSWORD_RESET) > 0) {
|
|
52
|
+
Emitter.emit(BastionEvents.PASSWORD_RESET, { user, ctx }).catch(() => {})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return ctx.json({ message: 'Password has been reset.' })
|
|
56
|
+
}
|