@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import type 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
|
+
import {
|
|
7
|
+
generateSecret,
|
|
8
|
+
totpUri,
|
|
9
|
+
verifyTotp,
|
|
10
|
+
base32Decode,
|
|
11
|
+
generateRecoveryCodes,
|
|
12
|
+
} from '../totp.ts'
|
|
13
|
+
import { completeLogin } from './login.ts'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// POST /two-factor/enable — generate a TOTP secret
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export async function enableTwoFactorHandler(ctx: Context): Promise<Response> {
|
|
20
|
+
const user = ctx.get('user')
|
|
21
|
+
const actions = BastionManager.actions
|
|
22
|
+
const config = BastionManager.config.twoFactor
|
|
23
|
+
|
|
24
|
+
// Don't allow re-enabling if already confirmed
|
|
25
|
+
const existingSecret = actions.twoFactorSecretOf!(user)
|
|
26
|
+
if (existingSecret) {
|
|
27
|
+
return ctx.json({ message: 'Two-factor authentication is already enabled.' }, 409)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { base32 } = generateSecret()
|
|
31
|
+
const email = actions.emailOf(user)
|
|
32
|
+
|
|
33
|
+
const uri = totpUri({
|
|
34
|
+
secret: base32,
|
|
35
|
+
issuer: config.issuer,
|
|
36
|
+
account: email,
|
|
37
|
+
digits: config.digits,
|
|
38
|
+
period: config.period,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Store the unconfirmed secret in the session (not persisted to user yet)
|
|
42
|
+
const session = ctx.get<Session>('session')
|
|
43
|
+
session.set('_bastion_2fa_secret', base32)
|
|
44
|
+
|
|
45
|
+
return ctx.json({ secret: base32, qr_uri: uri })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// POST /two-factor/confirm — confirm 2FA setup with a valid code
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export async function confirmTwoFactorHandler(ctx: Context): Promise<Response> {
|
|
53
|
+
const body = await ctx.body<{ code?: string }>()
|
|
54
|
+
if (!body.code) {
|
|
55
|
+
return ctx.json({ message: 'Verification code is required.' }, 422)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const user = ctx.get('user')
|
|
59
|
+
const actions = BastionManager.actions
|
|
60
|
+
const config = BastionManager.config.twoFactor
|
|
61
|
+
|
|
62
|
+
const session = ctx.get<Session>('session')
|
|
63
|
+
const pendingSecret = session.get<string>('_bastion_2fa_secret')
|
|
64
|
+
if (!pendingSecret) {
|
|
65
|
+
return ctx.json({ message: 'No pending two-factor setup. Call enable first.' }, 422)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Verify the code against the pending secret
|
|
69
|
+
const secretBytes = base32Decode(pendingSecret)
|
|
70
|
+
const valid = await verifyTotp(secretBytes, body.code, {
|
|
71
|
+
digits: config.digits,
|
|
72
|
+
period: config.period,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
if (!valid) {
|
|
76
|
+
return ctx.json({ message: 'Invalid verification code.' }, 422)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Persist the secret to the user
|
|
80
|
+
await actions.setTwoFactorSecret!(user, pendingSecret)
|
|
81
|
+
|
|
82
|
+
// Generate recovery codes
|
|
83
|
+
const codes = generateRecoveryCodes(config.recoveryCodes)
|
|
84
|
+
await actions.setRecoveryCodes!(user, codes)
|
|
85
|
+
|
|
86
|
+
// Clean up session
|
|
87
|
+
session.forget('_bastion_2fa_secret')
|
|
88
|
+
|
|
89
|
+
if (Emitter.listenerCount(BastionEvents.TWO_FACTOR_ENABLED) > 0) {
|
|
90
|
+
Emitter.emit(BastionEvents.TWO_FACTOR_ENABLED, { user, ctx }).catch(() => {})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return ctx.json({ message: 'Two-factor authentication enabled.', recovery_codes: codes })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// DELETE /two-factor — disable 2FA
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
export async function disableTwoFactorHandler(ctx: Context): Promise<Response> {
|
|
101
|
+
const user = ctx.get('user')
|
|
102
|
+
const actions = BastionManager.actions
|
|
103
|
+
|
|
104
|
+
await actions.setTwoFactorSecret!(user, null)
|
|
105
|
+
await actions.setRecoveryCodes!(user, [])
|
|
106
|
+
|
|
107
|
+
if (Emitter.listenerCount(BastionEvents.TWO_FACTOR_DISABLED) > 0) {
|
|
108
|
+
Emitter.emit(BastionEvents.TWO_FACTOR_DISABLED, { user, ctx }).catch(() => {})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return ctx.json({ message: 'Two-factor authentication disabled.' })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// POST /two-factor/challenge — verify TOTP code during login
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
export async function twoFactorChallengeHandler(ctx: Context): Promise<Response> {
|
|
119
|
+
const body = await ctx.body<{ code?: string; recovery_code?: string }>()
|
|
120
|
+
const session = ctx.get<Session>('session')
|
|
121
|
+
const actions = BastionManager.actions
|
|
122
|
+
const config = BastionManager.config.twoFactor
|
|
123
|
+
|
|
124
|
+
// Retrieve the pending login email from the session
|
|
125
|
+
const pendingEmail = session.get<string>('_bastion_2fa_email')
|
|
126
|
+
if (!pendingEmail) {
|
|
127
|
+
return ctx.json({ message: 'No pending two-factor challenge.' }, 422)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const user = await actions.findByEmail(pendingEmail)
|
|
131
|
+
if (!user) {
|
|
132
|
+
return ctx.json({ message: 'Invalid challenge.' }, 422)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const secret = actions.twoFactorSecretOf!(user)
|
|
136
|
+
if (!secret) {
|
|
137
|
+
return ctx.json({ message: 'Two-factor authentication is not enabled.' }, 422)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Try TOTP code first
|
|
141
|
+
if (body.code) {
|
|
142
|
+
const secretBytes = base32Decode(secret)
|
|
143
|
+
const valid = await verifyTotp(secretBytes, body.code, {
|
|
144
|
+
digits: config.digits,
|
|
145
|
+
period: config.period,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (!valid) {
|
|
149
|
+
return ctx.json({ message: 'Invalid two-factor code.' }, 422)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Try recovery code
|
|
153
|
+
else if (body.recovery_code) {
|
|
154
|
+
const codes = actions.recoveryCodesOf!(user)
|
|
155
|
+
const index = codes.indexOf(body.recovery_code)
|
|
156
|
+
if (index === -1) {
|
|
157
|
+
return ctx.json({ message: 'Invalid recovery code.' }, 422)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Remove the used recovery code
|
|
161
|
+
codes.splice(index, 1)
|
|
162
|
+
await actions.setRecoveryCodes!(user, codes)
|
|
163
|
+
} else {
|
|
164
|
+
return ctx.json({ message: 'A two-factor code or recovery code is required.' }, 422)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Clean up pending state
|
|
168
|
+
session.forget('_bastion_2fa_email')
|
|
169
|
+
|
|
170
|
+
// Complete the login
|
|
171
|
+
return completeLogin(ctx, user)
|
|
172
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import { encrypt } from '@stravigor/core/encryption'
|
|
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 updatePasswordHandler(ctx: Context): Promise<Response> {
|
|
8
|
+
const body = await ctx.body<{
|
|
9
|
+
current_password?: string
|
|
10
|
+
password?: string
|
|
11
|
+
password_confirmation?: string
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
// Validate
|
|
15
|
+
if (!body.current_password) {
|
|
16
|
+
return ctx.json({ message: 'Current password is required.' }, 422)
|
|
17
|
+
}
|
|
18
|
+
if (!body.password || body.password.length < 8) {
|
|
19
|
+
return ctx.json({ message: 'New 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
|
+
const user = ctx.get('user')
|
|
26
|
+
const hash = BastionManager.actions.passwordHashOf(user)
|
|
27
|
+
const valid = await encrypt.verify(body.current_password, hash)
|
|
28
|
+
|
|
29
|
+
if (!valid) {
|
|
30
|
+
return ctx.json({ message: 'Current password is incorrect.' }, 422)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await BastionManager.actions.updatePassword(user, body.password)
|
|
34
|
+
|
|
35
|
+
if (Emitter.listenerCount(BastionEvents.PASSWORD_UPDATED) > 0) {
|
|
36
|
+
Emitter.emit(BastionEvents.PASSWORD_UPDATED, { user, ctx }).catch(() => {})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return ctx.json({ message: 'Password updated.' })
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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 { BastionEvents } from '../types.ts'
|
|
5
|
+
|
|
6
|
+
export async function updateProfileHandler(ctx: Context): Promise<Response> {
|
|
7
|
+
const data = await ctx.body<Record<string, unknown>>()
|
|
8
|
+
const user = ctx.get('user')
|
|
9
|
+
|
|
10
|
+
await BastionManager.actions.updateProfile!(user, data)
|
|
11
|
+
|
|
12
|
+
if (Emitter.listenerCount(BastionEvents.PROFILE_UPDATED) > 0) {
|
|
13
|
+
Emitter.emit(BastionEvents.PROFILE_UPDATED, { user, ctx }).catch(() => {})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return ctx.json({ message: 'Profile updated.' })
|
|
17
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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 Emitter from '@stravigor/core/events/emitter'
|
|
5
|
+
import BastionManager from '../bastion_manager.ts'
|
|
6
|
+
import { createSignedToken, verifySignedToken } from '../tokens.ts'
|
|
7
|
+
import { BastionEvents } from '../types.ts'
|
|
8
|
+
|
|
9
|
+
/** Send a verification email for the given user. */
|
|
10
|
+
export async function sendVerificationEmail(user: unknown): Promise<void> {
|
|
11
|
+
const config = BastionManager.config
|
|
12
|
+
const actions = BastionManager.actions
|
|
13
|
+
const userId = extractUserId(user)
|
|
14
|
+
const email = actions.emailOf(user)
|
|
15
|
+
|
|
16
|
+
const token = createSignedToken(
|
|
17
|
+
{ sub: userId, typ: 'email-verify', email },
|
|
18
|
+
config.verification.expiration
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const verifyUrl = `${config.prefix}/email/verify/${encodeURIComponent(token)}`
|
|
22
|
+
|
|
23
|
+
await mail.to(email)
|
|
24
|
+
.subject('Verify Your Email')
|
|
25
|
+
.template('bastion.verify-email', { verifyUrl, expiration: config.verification.expiration })
|
|
26
|
+
.send()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** POST /email/send — resend verification email. */
|
|
30
|
+
export async function sendVerificationHandler(ctx: Context): Promise<Response> {
|
|
31
|
+
const user = ctx.get('user')
|
|
32
|
+
const actions = BastionManager.actions
|
|
33
|
+
|
|
34
|
+
if (actions.isEmailVerified!(user)) {
|
|
35
|
+
return ctx.json({ message: 'Email already verified.' })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await sendVerificationEmail(user)
|
|
39
|
+
return ctx.json({ message: 'Verification email sent.' })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** GET /email/verify/:token — verify the email. */
|
|
43
|
+
export async function verifyEmailHandler(ctx: Context): Promise<Response> {
|
|
44
|
+
const token = ctx.params.token as string | undefined
|
|
45
|
+
if (!token) {
|
|
46
|
+
return ctx.json({ message: 'Invalid verification link.' }, 422)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let payload: { sub: string | number; typ: string; email: string }
|
|
50
|
+
try {
|
|
51
|
+
payload = verifySignedToken(token)
|
|
52
|
+
} catch {
|
|
53
|
+
return ctx.json({ message: 'Invalid or expired verification link.' }, 422)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (payload.typ !== 'email-verify') {
|
|
57
|
+
return ctx.json({ message: 'Invalid token type.' }, 422)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const user = await BastionManager.actions.findById(payload.sub)
|
|
61
|
+
if (!user) {
|
|
62
|
+
return ctx.json({ message: 'Invalid verification link.' }, 422)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Verify the email still matches
|
|
66
|
+
if (BastionManager.actions.emailOf(user) !== payload.email) {
|
|
67
|
+
return ctx.json({ message: 'Invalid verification link.' }, 422)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const actions = BastionManager.actions
|
|
71
|
+
|
|
72
|
+
if (actions.isEmailVerified!(user)) {
|
|
73
|
+
return ctx.json({ message: 'Email already verified.' })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await actions.markEmailVerified!(user)
|
|
77
|
+
|
|
78
|
+
if (Emitter.listenerCount(BastionEvents.EMAIL_VERIFIED) > 0) {
|
|
79
|
+
Emitter.emit(BastionEvents.EMAIL_VERIFIED, { user, ctx }).catch(() => {})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return ctx.json({ message: 'Email verified.' })
|
|
83
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import BastionManager from './bastion_manager.ts'
|
|
2
|
+
import { createSignedToken, verifySignedToken } from './tokens.ts'
|
|
3
|
+
import {
|
|
4
|
+
generateSecret,
|
|
5
|
+
totpUri,
|
|
6
|
+
verifyTotp,
|
|
7
|
+
base32Decode,
|
|
8
|
+
generateRecoveryCodes,
|
|
9
|
+
} from './totp.ts'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Bastion helper — convenience API for auth flow utilities.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* import { bastion } from '@stravigor/bastion'
|
|
16
|
+
*
|
|
17
|
+
* const token = bastion.signedToken({ sub: user.id, typ: 'custom' }, 60)
|
|
18
|
+
* const payload = bastion.verifyToken(token)
|
|
19
|
+
*
|
|
20
|
+
* const { secret, qrUri } = bastion.generateTwoFactorSecret(user)
|
|
21
|
+
* const valid = await bastion.verifyTwoFactorCode(secret, code)
|
|
22
|
+
*/
|
|
23
|
+
export const bastion = {
|
|
24
|
+
/** Check if a feature is enabled. */
|
|
25
|
+
hasFeature(feature: string): boolean {
|
|
26
|
+
return BastionManager.hasFeature(feature as any)
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/** Create a signed, encrypted token with an expiration. */
|
|
30
|
+
signedToken(data: { sub: string | number; typ: string; [key: string]: unknown }, expiresInMinutes: number): string {
|
|
31
|
+
return createSignedToken(data, expiresInMinutes)
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/** Verify and decode a signed token. Throws if expired or tampered. */
|
|
35
|
+
verifyToken<T extends Record<string, unknown> = Record<string, unknown>>(token: string): T {
|
|
36
|
+
return verifySignedToken<T>(token)
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/** Generate a TOTP secret and QR URI for the given user. */
|
|
40
|
+
generateTwoFactorSecret(user: unknown): { secret: string; qrUri: string } {
|
|
41
|
+
const config = BastionManager.config.twoFactor
|
|
42
|
+
const { base32 } = generateSecret()
|
|
43
|
+
const email = BastionManager.actions.emailOf(user)
|
|
44
|
+
|
|
45
|
+
const uri = totpUri({
|
|
46
|
+
secret: base32,
|
|
47
|
+
issuer: config.issuer,
|
|
48
|
+
account: email,
|
|
49
|
+
digits: config.digits,
|
|
50
|
+
period: config.period,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return { secret: base32, qrUri: uri }
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/** Verify a TOTP code against a base32 secret. */
|
|
57
|
+
async verifyTwoFactorCode(base32Secret: string, code: string): Promise<boolean> {
|
|
58
|
+
const config = BastionManager.config.twoFactor
|
|
59
|
+
const secretBytes = base32Decode(base32Secret)
|
|
60
|
+
return verifyTotp(secretBytes, code, {
|
|
61
|
+
digits: config.digits,
|
|
62
|
+
period: config.period,
|
|
63
|
+
})
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/** Generate a set of single-use recovery codes. */
|
|
67
|
+
generateRecoveryCodes(count?: number): string[] {
|
|
68
|
+
return generateRecoveryCodes(count ?? BastionManager.config.twoFactor.recoveryCodes)
|
|
69
|
+
},
|
|
70
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Manager & provider
|
|
2
|
+
export { default, default as BastionManager } from './bastion_manager.ts'
|
|
3
|
+
export { default as BastionProvider } from './bastion_provider.ts'
|
|
4
|
+
|
|
5
|
+
// Helper
|
|
6
|
+
export { bastion } from './helpers.ts'
|
|
7
|
+
|
|
8
|
+
// Actions
|
|
9
|
+
export { defineActions } from './actions.ts'
|
|
10
|
+
|
|
11
|
+
// Middleware
|
|
12
|
+
export { verified } from './middleware/verified.ts'
|
|
13
|
+
export { confirmed } from './middleware/confirmed.ts'
|
|
14
|
+
export { twoFactorChallenge } from './middleware/two_factor_challenge.ts'
|
|
15
|
+
|
|
16
|
+
// Handlers (for manual route registration)
|
|
17
|
+
export { registerHandler } from './handlers/register.ts'
|
|
18
|
+
export { loginHandler } from './handlers/login.ts'
|
|
19
|
+
export { logoutHandler } from './handlers/logout.ts'
|
|
20
|
+
export { forgotPasswordHandler } from './handlers/forgot_password.ts'
|
|
21
|
+
export { resetPasswordHandler } from './handlers/reset_password.ts'
|
|
22
|
+
export { sendVerificationHandler, verifyEmailHandler } from './handlers/verify_email.ts'
|
|
23
|
+
export {
|
|
24
|
+
enableTwoFactorHandler,
|
|
25
|
+
confirmTwoFactorHandler,
|
|
26
|
+
disableTwoFactorHandler,
|
|
27
|
+
twoFactorChallengeHandler,
|
|
28
|
+
} from './handlers/two_factor.ts'
|
|
29
|
+
export { confirmPasswordHandler } from './handlers/confirm_password.ts'
|
|
30
|
+
export { updatePasswordHandler } from './handlers/update_password.ts'
|
|
31
|
+
export { updateProfileHandler } from './handlers/update_profile.ts'
|
|
32
|
+
|
|
33
|
+
// Errors
|
|
34
|
+
export { BastionError, MissingActionError, ValidationError } from './errors.ts'
|
|
35
|
+
|
|
36
|
+
// Types
|
|
37
|
+
export type {
|
|
38
|
+
Feature,
|
|
39
|
+
BastionActions,
|
|
40
|
+
BastionConfig,
|
|
41
|
+
BastionEvent,
|
|
42
|
+
RegistrationData,
|
|
43
|
+
RateLimitConfig,
|
|
44
|
+
} from './types.ts'
|
|
45
|
+
export { BastionEvents } from './types.ts'
|
|
46
|
+
|
|
47
|
+
// TOTP utilities
|
|
48
|
+
export { generateSecret, totpUri, verifyTotp, generateRecoveryCodes, base32Encode, base32Decode } from './totp.ts'
|
|
49
|
+
|
|
50
|
+
// Token utilities
|
|
51
|
+
export { createSignedToken, verifySignedToken } from './tokens.ts'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type Session from '@stravigor/core/session/session'
|
|
2
|
+
import type { Middleware } from '@stravigor/core/http/middleware'
|
|
3
|
+
import BastionManager from '../bastion_manager.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Require the user to have confirmed their password recently.
|
|
7
|
+
* Returns 423 (Locked) if the confirmation has expired.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* router.delete('/account', auth(), confirmed(), deleteAccountHandler)
|
|
11
|
+
*/
|
|
12
|
+
export function confirmed(): Middleware {
|
|
13
|
+
return (ctx, next) => {
|
|
14
|
+
const session = ctx.get<Session>('session')
|
|
15
|
+
const confirmedAt = session.get<number>('_bastion_confirmed_at')
|
|
16
|
+
|
|
17
|
+
if (!confirmedAt) {
|
|
18
|
+
return ctx.json({ message: 'Password confirmation required.' }, 423)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const timeout = BastionManager.config.confirmation.timeout * 1000
|
|
22
|
+
if (Date.now() - confirmedAt > timeout) {
|
|
23
|
+
return ctx.json({ message: 'Password confirmation has expired.' }, 423)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return next()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type Session from '@stravigor/core/session/session'
|
|
2
|
+
import type { Middleware } from '@stravigor/core/http/middleware'
|
|
3
|
+
import BastionManager from '../bastion_manager.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Require a completed two-factor challenge for the current session.
|
|
7
|
+
* Returns 403 if the user has 2FA enabled but hasn't completed the challenge.
|
|
8
|
+
*
|
|
9
|
+
* Useful for protecting sensitive routes beyond the initial login.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* router.post('/transfer', auth(), twoFactorChallenge(), transferHandler)
|
|
13
|
+
*/
|
|
14
|
+
export function twoFactorChallenge(): Middleware {
|
|
15
|
+
return (ctx, next) => {
|
|
16
|
+
const user = ctx.get('user')
|
|
17
|
+
const actions = BastionManager.actions
|
|
18
|
+
|
|
19
|
+
// If 2FA is not enabled for this user, let them through
|
|
20
|
+
if (!actions.twoFactorSecretOf) return next()
|
|
21
|
+
const secret = actions.twoFactorSecretOf(user)
|
|
22
|
+
if (!secret) return next()
|
|
23
|
+
|
|
24
|
+
// Check if there's still a pending 2FA challenge in the session
|
|
25
|
+
const session = ctx.get<Session>('session')
|
|
26
|
+
if (session.has('_bastion_2fa_email')) {
|
|
27
|
+
return ctx.json({ message: 'Two-factor authentication required.' }, 403)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return next()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Middleware } from '@stravigor/core/http/middleware'
|
|
2
|
+
import BastionManager from '../bastion_manager.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Require the authenticated user to have a verified email address.
|
|
6
|
+
* Returns 403 if the email is not verified.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* router.group({ middleware: [auth(), verified()] }, r => {
|
|
10
|
+
* r.get('/dashboard', dashboardHandler)
|
|
11
|
+
* })
|
|
12
|
+
*/
|
|
13
|
+
export function verified(): Middleware {
|
|
14
|
+
return (ctx, next) => {
|
|
15
|
+
const user = ctx.get('user')
|
|
16
|
+
const actions = BastionManager.actions
|
|
17
|
+
|
|
18
|
+
if (!actions.isEmailVerified!(user)) {
|
|
19
|
+
return ctx.json({ message: 'Email not verified.' }, 403)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return next()
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { encrypt } from '@stravigor/core/encryption'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Signed token payloads
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export interface TokenPayload {
|
|
8
|
+
/** User identifier. */
|
|
9
|
+
sub: string | number
|
|
10
|
+
/** Token purpose. */
|
|
11
|
+
typ: string
|
|
12
|
+
/** Issued at (unix ms). */
|
|
13
|
+
iat: number
|
|
14
|
+
/** Expiration (minutes from iat). */
|
|
15
|
+
exp: number
|
|
16
|
+
/** Extra data. */
|
|
17
|
+
[key: string]: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a signed, encrypted token using `encrypt.seal()`.
|
|
22
|
+
* Tamper-proof and opaque — the user cannot read or modify the payload.
|
|
23
|
+
*
|
|
24
|
+
* @param data - The payload to sign.
|
|
25
|
+
* @param expiresInMinutes - Token lifetime in minutes.
|
|
26
|
+
*/
|
|
27
|
+
export function createSignedToken(
|
|
28
|
+
data: { sub: string | number; typ: string; [key: string]: unknown },
|
|
29
|
+
expiresInMinutes: number
|
|
30
|
+
): string {
|
|
31
|
+
const payload: TokenPayload = {
|
|
32
|
+
...data,
|
|
33
|
+
iat: Date.now(),
|
|
34
|
+
exp: expiresInMinutes,
|
|
35
|
+
}
|
|
36
|
+
return encrypt.seal(payload)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Verify and decode a signed token. Throws if expired or tampered.
|
|
41
|
+
*
|
|
42
|
+
* @returns The original payload.
|
|
43
|
+
* @throws If the token is invalid, expired, or tampered.
|
|
44
|
+
*/
|
|
45
|
+
export function verifySignedToken<T = TokenPayload>(token: string): T {
|
|
46
|
+
const payload = encrypt.unseal<TokenPayload>(token)
|
|
47
|
+
|
|
48
|
+
if (!payload || typeof payload !== 'object' || !payload.iat) {
|
|
49
|
+
throw new Error('Invalid token payload.')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (payload.exp) {
|
|
53
|
+
const expiresAt = payload.iat + payload.exp * 60_000
|
|
54
|
+
if (Date.now() > expiresAt) {
|
|
55
|
+
throw new Error('Token has expired.')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return payload as unknown as T
|
|
60
|
+
}
|