@strav/jina 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 +73 -0
- package/package.json +27 -0
- package/src/actions.ts +22 -0
- package/src/errors.ts +21 -0
- package/src/handlers/confirm_password.ts +30 -0
- package/src/handlers/forgot_password.ts +37 -0
- package/src/handlers/login.ts +63 -0
- package/src/handlers/logout.ts +23 -0
- package/src/handlers/register.ts +72 -0
- package/src/handlers/reset_password.ts +56 -0
- package/src/handlers/two_factor.ts +171 -0
- package/src/handlers/update_password.ts +39 -0
- package/src/handlers/update_profile.ts +17 -0
- package/src/handlers/verify_email.ts +84 -0
- package/src/helpers.ts +67 -0
- package/src/index.ts +58 -0
- package/src/jina_manager.ts +200 -0
- package/src/jina_provider.ts +25 -0
- package/src/middleware/confirmed.ts +27 -0
- package/src/middleware/two_factor_challenge.ts +31 -0
- package/src/middleware/verified.ts +24 -0
- package/src/tokens.ts +60 -0
- package/src/totp.ts +171 -0
- package/src/types.ts +135 -0
- package/stubs/actions/jina.ts +83 -0
- package/stubs/config/jina.ts +55 -0
- package/stubs/emails/reset-password.strav +26 -0
- package/stubs/emails/verify-email.strav +26 -0
- package/tsconfig.json +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @stravigor/jina
|
|
2
|
+
|
|
3
|
+
Headless authentication flows for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Registration, login, logout, password reset, email verification, two-factor authentication (TOTP), password confirmation, and profile updates — all as JSON API endpoints.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @stravigor/jina
|
|
9
|
+
bun strav install jina
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires `@stravigor/core` as a peer dependency.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { defineActions } from '@stravigor/jina'
|
|
18
|
+
import User from './models/user'
|
|
19
|
+
|
|
20
|
+
const actions = defineActions<User>({
|
|
21
|
+
async createUser(data) { return User.create(data) },
|
|
22
|
+
async findByEmail(email) { return User.query().where('email', email).first() },
|
|
23
|
+
async findById(id) { return User.find(id) },
|
|
24
|
+
passwordHashOf(user) { return user.password },
|
|
25
|
+
emailOf(user) { return user.email },
|
|
26
|
+
async updatePassword(user, pw) { user.password = pw; await user.save() },
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { JinaProvider } from '@stravigor/jina'
|
|
32
|
+
|
|
33
|
+
app.use(new JinaProvider(actions))
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Routes
|
|
37
|
+
|
|
38
|
+
Routes are registered automatically:
|
|
39
|
+
|
|
40
|
+
| Method | Path | Feature |
|
|
41
|
+
|--------|------|---------|
|
|
42
|
+
| POST | `/register` | registration |
|
|
43
|
+
| POST | `/login` | login |
|
|
44
|
+
| POST | `/logout` | logout |
|
|
45
|
+
| POST | `/forgot-password` | password-reset |
|
|
46
|
+
| POST | `/reset-password` | password-reset |
|
|
47
|
+
| POST | `/email/send` | email-verification |
|
|
48
|
+
| GET | `/email/verify/:token` | email-verification |
|
|
49
|
+
| POST | `/two-factor/enable` | two-factor |
|
|
50
|
+
| POST | `/two-factor/confirm` | two-factor |
|
|
51
|
+
| DELETE | `/two-factor` | two-factor |
|
|
52
|
+
| POST | `/two-factor/challenge` | two-factor |
|
|
53
|
+
| POST | `/confirm-password` | password-confirmation |
|
|
54
|
+
| PUT | `/password` | update-password |
|
|
55
|
+
| PUT | `/profile` | update-profile |
|
|
56
|
+
|
|
57
|
+
## Middleware
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { verified, confirmed, twoFactorChallenge } from '@stravigor/jina'
|
|
61
|
+
|
|
62
|
+
router.group({ middleware: [auth(), verified()] }, r => {
|
|
63
|
+
r.delete('/account', compose([confirmed()], deleteAccountHandler))
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Documentation
|
|
68
|
+
|
|
69
|
+
See the full [Jina guide](../../guides/jina.md).
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/jina",
|
|
3
|
+
"version": "0.1.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": [
|
|
12
|
+
"src/",
|
|
13
|
+
"stubs/",
|
|
14
|
+
"package.json",
|
|
15
|
+
"tsconfig.json"
|
|
16
|
+
],
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@strav/kernel": "0.1.0",
|
|
19
|
+
"@strav/http": "0.1.0",
|
|
20
|
+
"@strav/signal": "0.1.0",
|
|
21
|
+
"@strav/database": "0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test tests/",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { JinaActions } from './types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type-safe identity function for defining Jina actions.
|
|
5
|
+
* Zero runtime cost — just provides autocompletion and type checking.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { defineActions } from '@stravigor/jina'
|
|
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>(actions: JinaActions<TUser>): JinaActions<TUser> {
|
|
21
|
+
return actions
|
|
22
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { StravError } from '@stravigor/kernel'
|
|
2
|
+
|
|
3
|
+
/** Base error for all Jina errors. */
|
|
4
|
+
export class JinaError extends StravError {}
|
|
5
|
+
|
|
6
|
+
/** Thrown when a required action is missing for an enabled feature. */
|
|
7
|
+
export class MissingActionError extends JinaError {
|
|
8
|
+
constructor(action: string, feature: string) {
|
|
9
|
+
super(`Jina action "${action}" is required when the "${feature}" feature is enabled.`)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Thrown when input validation fails. */
|
|
14
|
+
export class ValidationError extends JinaError {
|
|
15
|
+
constructor(
|
|
16
|
+
message: string,
|
|
17
|
+
public readonly errors: Record<string, string> = {}
|
|
18
|
+
) {
|
|
19
|
+
super(message)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { encrypt, Emitter } from '@stravigor/kernel'
|
|
2
|
+
import type { Context, Session } from '@stravigor/http'
|
|
3
|
+
import JinaManager from '../jina_manager.ts'
|
|
4
|
+
import { JinaEvents } from '../types.ts'
|
|
5
|
+
|
|
6
|
+
export async function confirmPasswordHandler(ctx: Context): Promise<Response> {
|
|
7
|
+
const body = await ctx.body<{ password?: string }>()
|
|
8
|
+
|
|
9
|
+
if (!body.password) {
|
|
10
|
+
return ctx.json({ message: 'Password is required.' }, 422)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const user = ctx.get('user')
|
|
14
|
+
const hash = JinaManager.actions.passwordHashOf(user)
|
|
15
|
+
const valid = await encrypt.verify(body.password, hash)
|
|
16
|
+
|
|
17
|
+
if (!valid) {
|
|
18
|
+
return ctx.json({ message: 'Invalid password.' }, 422)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Store confirmation timestamp in session
|
|
22
|
+
const session = ctx.get<Session>('session')
|
|
23
|
+
session.set('_jina_confirmed_at', Date.now())
|
|
24
|
+
|
|
25
|
+
if (Emitter.listenerCount(JinaEvents.PASSWORD_CONFIRMED) > 0) {
|
|
26
|
+
Emitter.emit(JinaEvents.PASSWORD_CONFIRMED, { user, ctx }).catch(() => {})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return ctx.json({ message: 'Password confirmed.' })
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mail } from '@stravigor/signal'
|
|
2
|
+
import { extractUserId } from '@stravigor/database'
|
|
3
|
+
import type { Context } from '@stravigor/http'
|
|
4
|
+
import JinaManager from '../jina_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 JinaManager.actions.findByEmail(body.email)
|
|
16
|
+
|
|
17
|
+
if (user) {
|
|
18
|
+
const config = JinaManager.config
|
|
19
|
+
const userId = extractUserId(user)
|
|
20
|
+
const email = JinaManager.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
|
|
30
|
+
.to(email)
|
|
31
|
+
.subject('Reset Your Password')
|
|
32
|
+
.template('jina.reset-password', { resetUrl, expiration: config.passwords.expiration })
|
|
33
|
+
.send()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return ctx.json({ message: 'If an account exists, a reset link has been sent.' })
|
|
37
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { encrypt, Emitter } from '@stravigor/kernel'
|
|
2
|
+
import { AccessToken } from '@stravigor/http'
|
|
3
|
+
import type { Context, Session } from '@stravigor/http'
|
|
4
|
+
import JinaManager from '../jina_manager.ts'
|
|
5
|
+
import { JinaEvents } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
export async function loginHandler(ctx: Context): Promise<Response> {
|
|
8
|
+
const body = await ctx.body<{ email?: string; password?: string }>()
|
|
9
|
+
const { email, password } = body
|
|
10
|
+
|
|
11
|
+
// Validate
|
|
12
|
+
if (!email || !password) {
|
|
13
|
+
return ctx.json({ message: 'Email and password are required.' }, 422)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Find user
|
|
17
|
+
const user = await JinaManager.actions.findByEmail(email)
|
|
18
|
+
if (!user) {
|
|
19
|
+
return ctx.json({ message: 'Invalid credentials.' }, 401)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Verify password
|
|
23
|
+
const hash = JinaManager.actions.passwordHashOf(user)
|
|
24
|
+
const valid = await encrypt.verify(password, hash)
|
|
25
|
+
if (!valid) {
|
|
26
|
+
return ctx.json({ message: 'Invalid credentials.' }, 401)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Two-factor challenge
|
|
30
|
+
if (JinaManager.hasFeature('two-factor') && JinaManager.actions.twoFactorSecretOf) {
|
|
31
|
+
const secret = JinaManager.actions.twoFactorSecretOf(user)
|
|
32
|
+
if (secret) {
|
|
33
|
+
// Store the user email in session for the challenge step
|
|
34
|
+
const session = ctx.get<Session>('session')
|
|
35
|
+
session.set('_jina_2fa_email', email)
|
|
36
|
+
return ctx.json({ two_factor: true })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return completeLogin(ctx, user)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Finalize login — authenticate session or issue token. */
|
|
44
|
+
export async function completeLogin(ctx: Context, user: unknown): Promise<Response> {
|
|
45
|
+
const config = JinaManager.config
|
|
46
|
+
|
|
47
|
+
if (config.mode === 'session') {
|
|
48
|
+
const session = ctx.get<Session>('session')
|
|
49
|
+
session.authenticate(user)
|
|
50
|
+
await session.regenerate()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Emitter.listenerCount(JinaEvents.LOGIN) > 0) {
|
|
54
|
+
Emitter.emit(JinaEvents.LOGIN, { user, ctx }).catch(() => {})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (config.mode === 'token') {
|
|
58
|
+
const { token, accessToken } = await AccessToken.create(user, 'login')
|
|
59
|
+
return ctx.json({ user, token, accessToken })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return ctx.json({ user })
|
|
63
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Emitter } from '@stravigor/kernel'
|
|
2
|
+
import { Session } from '@stravigor/http'
|
|
3
|
+
import type { Context } from '@stravigor/http'
|
|
4
|
+
import JinaManager from '../jina_manager.ts'
|
|
5
|
+
import { JinaEvents } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
export async function logoutHandler(ctx: Context): Promise<Response> {
|
|
8
|
+
const user = ctx.get('user')
|
|
9
|
+
|
|
10
|
+
if (Emitter.listenerCount(JinaEvents.LOGOUT) > 0) {
|
|
11
|
+
Emitter.emit(JinaEvents.LOGOUT, { user, ctx }).catch(() => {})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (JinaManager.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,72 @@
|
|
|
1
|
+
import { Emitter } from '@stravigor/kernel'
|
|
2
|
+
import { AccessToken } from '@stravigor/http'
|
|
3
|
+
import type { Context, Session } from '@stravigor/http'
|
|
4
|
+
import JinaManager from '../jina_manager.ts'
|
|
5
|
+
import { ValidationError } from '../errors.ts'
|
|
6
|
+
import { JinaEvents } from '../types.ts'
|
|
7
|
+
import { sendVerificationEmail } from './verify_email.ts'
|
|
8
|
+
|
|
9
|
+
export async function registerHandler(ctx: Context): Promise<Response> {
|
|
10
|
+
const body = await ctx.body<Record<string, unknown>>()
|
|
11
|
+
const { name, email, password, password_confirmation } = body as {
|
|
12
|
+
name?: string
|
|
13
|
+
email?: string
|
|
14
|
+
password?: string
|
|
15
|
+
password_confirmation?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Validate
|
|
19
|
+
const errors: Record<string, string> = {}
|
|
20
|
+
if (!name || typeof name !== 'string') errors.name = 'Name is required.'
|
|
21
|
+
if (!email || typeof email !== 'string') errors.email = 'Email is required.'
|
|
22
|
+
if (!password || typeof password !== 'string') errors.password = 'Password is required.'
|
|
23
|
+
else if (password.length < 8) errors.password = 'Password must be at least 8 characters.'
|
|
24
|
+
if (password !== password_confirmation) errors.password_confirmation = 'Passwords do not match.'
|
|
25
|
+
|
|
26
|
+
if (Object.keys(errors).length > 0) {
|
|
27
|
+
return ctx.json({ message: 'Validation failed.', errors }, 422)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check if email is already taken
|
|
31
|
+
const existing = await JinaManager.actions.findByEmail(email!)
|
|
32
|
+
if (existing) {
|
|
33
|
+
return ctx.json(
|
|
34
|
+
{ message: 'Validation failed.', errors: { email: 'Email already taken.' } },
|
|
35
|
+
422
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Create user
|
|
40
|
+
const user = await JinaManager.actions.createUser({
|
|
41
|
+
name: name!,
|
|
42
|
+
email: email!,
|
|
43
|
+
password: password!,
|
|
44
|
+
...body,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const config = JinaManager.config
|
|
48
|
+
|
|
49
|
+
// Authenticate
|
|
50
|
+
if (config.mode === 'session') {
|
|
51
|
+
const session = ctx.get<Session>('session')
|
|
52
|
+
session.authenticate(user)
|
|
53
|
+
await session.regenerate()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Emit
|
|
57
|
+
if (Emitter.listenerCount(JinaEvents.REGISTERED) > 0) {
|
|
58
|
+
Emitter.emit(JinaEvents.REGISTERED, { user, ctx }).catch(() => {})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Auto-send verification email if feature enabled
|
|
62
|
+
if (JinaManager.hasFeature('email-verification')) {
|
|
63
|
+
sendVerificationEmail(user).catch(() => {})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (config.mode === 'token') {
|
|
67
|
+
const { token, accessToken } = await AccessToken.create(user, 'registration')
|
|
68
|
+
return ctx.json({ user, token, accessToken }, 201)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return ctx.json({ user }, 201)
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Emitter } from '@stravigor/kernel'
|
|
2
|
+
import type { Context } from '@stravigor/http'
|
|
3
|
+
import JinaManager from '../jina_manager.ts'
|
|
4
|
+
import { verifySignedToken } from '../tokens.ts'
|
|
5
|
+
import { JinaEvents } 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 JinaManager.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 (JinaManager.actions.emailOf(user) !== payload.email) {
|
|
45
|
+
return ctx.json({ message: 'Invalid or expired reset token.' }, 422)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Update password
|
|
49
|
+
await JinaManager.actions.updatePassword(user, body.password)
|
|
50
|
+
|
|
51
|
+
if (Emitter.listenerCount(JinaEvents.PASSWORD_RESET) > 0) {
|
|
52
|
+
Emitter.emit(JinaEvents.PASSWORD_RESET, { user, ctx }).catch(() => {})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return ctx.json({ message: 'Password has been reset.' })
|
|
56
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Emitter } from '@stravigor/kernel'
|
|
2
|
+
import type { Context, Session } from '@stravigor/http'
|
|
3
|
+
import JinaManager from '../jina_manager.ts'
|
|
4
|
+
import { JinaEvents } from '../types.ts'
|
|
5
|
+
import {
|
|
6
|
+
generateSecret,
|
|
7
|
+
totpUri,
|
|
8
|
+
verifyTotp,
|
|
9
|
+
base32Decode,
|
|
10
|
+
generateRecoveryCodes,
|
|
11
|
+
} from '../totp.ts'
|
|
12
|
+
import { completeLogin } from './login.ts'
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// POST /two-factor/enable — generate a TOTP secret
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export async function enableTwoFactorHandler(ctx: Context): Promise<Response> {
|
|
19
|
+
const user = ctx.get('user')
|
|
20
|
+
const actions = JinaManager.actions
|
|
21
|
+
const config = JinaManager.config.twoFactor
|
|
22
|
+
|
|
23
|
+
// Don't allow re-enabling if already confirmed
|
|
24
|
+
const existingSecret = actions.twoFactorSecretOf!(user)
|
|
25
|
+
if (existingSecret) {
|
|
26
|
+
return ctx.json({ message: 'Two-factor authentication is already enabled.' }, 409)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { base32 } = generateSecret()
|
|
30
|
+
const email = actions.emailOf(user)
|
|
31
|
+
|
|
32
|
+
const uri = totpUri({
|
|
33
|
+
secret: base32,
|
|
34
|
+
issuer: config.issuer,
|
|
35
|
+
account: email,
|
|
36
|
+
digits: config.digits,
|
|
37
|
+
period: config.period,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// Store the unconfirmed secret in the session (not persisted to user yet)
|
|
41
|
+
const session = ctx.get<Session>('session')
|
|
42
|
+
session.set('_jina_2fa_secret', base32)
|
|
43
|
+
|
|
44
|
+
return ctx.json({ secret: base32, qr_uri: uri })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// POST /two-factor/confirm — confirm 2FA setup with a valid code
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export async function confirmTwoFactorHandler(ctx: Context): Promise<Response> {
|
|
52
|
+
const body = await ctx.body<{ code?: string }>()
|
|
53
|
+
if (!body.code) {
|
|
54
|
+
return ctx.json({ message: 'Verification code is required.' }, 422)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const user = ctx.get('user')
|
|
58
|
+
const actions = JinaManager.actions
|
|
59
|
+
const config = JinaManager.config.twoFactor
|
|
60
|
+
|
|
61
|
+
const session = ctx.get<Session>('session')
|
|
62
|
+
const pendingSecret = session.get<string>('_jina_2fa_secret')
|
|
63
|
+
if (!pendingSecret) {
|
|
64
|
+
return ctx.json({ message: 'No pending two-factor setup. Call enable first.' }, 422)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Verify the code against the pending secret
|
|
68
|
+
const secretBytes = base32Decode(pendingSecret)
|
|
69
|
+
const valid = await verifyTotp(secretBytes, body.code, {
|
|
70
|
+
digits: config.digits,
|
|
71
|
+
period: config.period,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (!valid) {
|
|
75
|
+
return ctx.json({ message: 'Invalid verification code.' }, 422)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Persist the secret to the user
|
|
79
|
+
await actions.setTwoFactorSecret!(user, pendingSecret)
|
|
80
|
+
|
|
81
|
+
// Generate recovery codes
|
|
82
|
+
const codes = generateRecoveryCodes(config.recoveryCodes)
|
|
83
|
+
await actions.setRecoveryCodes!(user, codes)
|
|
84
|
+
|
|
85
|
+
// Clean up session
|
|
86
|
+
session.forget('_jina_2fa_secret')
|
|
87
|
+
|
|
88
|
+
if (Emitter.listenerCount(JinaEvents.TWO_FACTOR_ENABLED) > 0) {
|
|
89
|
+
Emitter.emit(JinaEvents.TWO_FACTOR_ENABLED, { user, ctx }).catch(() => {})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return ctx.json({ message: 'Two-factor authentication enabled.', recovery_codes: codes })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// DELETE /two-factor — disable 2FA
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export async function disableTwoFactorHandler(ctx: Context): Promise<Response> {
|
|
100
|
+
const user = ctx.get('user')
|
|
101
|
+
const actions = JinaManager.actions
|
|
102
|
+
|
|
103
|
+
await actions.setTwoFactorSecret!(user, null)
|
|
104
|
+
await actions.setRecoveryCodes!(user, [])
|
|
105
|
+
|
|
106
|
+
if (Emitter.listenerCount(JinaEvents.TWO_FACTOR_DISABLED) > 0) {
|
|
107
|
+
Emitter.emit(JinaEvents.TWO_FACTOR_DISABLED, { user, ctx }).catch(() => {})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return ctx.json({ message: 'Two-factor authentication disabled.' })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// POST /two-factor/challenge — verify TOTP code during login
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export async function twoFactorChallengeHandler(ctx: Context): Promise<Response> {
|
|
118
|
+
const body = await ctx.body<{ code?: string; recovery_code?: string }>()
|
|
119
|
+
const session = ctx.get<Session>('session')
|
|
120
|
+
const actions = JinaManager.actions
|
|
121
|
+
const config = JinaManager.config.twoFactor
|
|
122
|
+
|
|
123
|
+
// Retrieve the pending login email from the session
|
|
124
|
+
const pendingEmail = session.get<string>('_jina_2fa_email')
|
|
125
|
+
if (!pendingEmail) {
|
|
126
|
+
return ctx.json({ message: 'No pending two-factor challenge.' }, 422)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const user = await actions.findByEmail(pendingEmail)
|
|
130
|
+
if (!user) {
|
|
131
|
+
return ctx.json({ message: 'Invalid challenge.' }, 422)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const secret = actions.twoFactorSecretOf!(user)
|
|
135
|
+
if (!secret) {
|
|
136
|
+
return ctx.json({ message: 'Two-factor authentication is not enabled.' }, 422)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Try TOTP code first
|
|
140
|
+
if (body.code) {
|
|
141
|
+
const secretBytes = base32Decode(secret)
|
|
142
|
+
const valid = await verifyTotp(secretBytes, body.code, {
|
|
143
|
+
digits: config.digits,
|
|
144
|
+
period: config.period,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
if (!valid) {
|
|
148
|
+
return ctx.json({ message: 'Invalid two-factor code.' }, 422)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Try recovery code
|
|
152
|
+
else if (body.recovery_code) {
|
|
153
|
+
const codes = actions.recoveryCodesOf!(user)
|
|
154
|
+
const index = codes.indexOf(body.recovery_code)
|
|
155
|
+
if (index === -1) {
|
|
156
|
+
return ctx.json({ message: 'Invalid recovery code.' }, 422)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Remove the used recovery code
|
|
160
|
+
codes.splice(index, 1)
|
|
161
|
+
await actions.setRecoveryCodes!(user, codes)
|
|
162
|
+
} else {
|
|
163
|
+
return ctx.json({ message: 'A two-factor code or recovery code is required.' }, 422)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Clean up pending state
|
|
167
|
+
session.forget('_jina_2fa_email')
|
|
168
|
+
|
|
169
|
+
// Complete the login
|
|
170
|
+
return completeLogin(ctx, user)
|
|
171
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { encrypt, Emitter } from '@stravigor/kernel'
|
|
2
|
+
import type { Context } from '@stravigor/http'
|
|
3
|
+
import JinaManager from '../jina_manager.ts'
|
|
4
|
+
import { JinaEvents } from '../types.ts'
|
|
5
|
+
|
|
6
|
+
export async function updatePasswordHandler(ctx: Context): Promise<Response> {
|
|
7
|
+
const body = await ctx.body<{
|
|
8
|
+
current_password?: string
|
|
9
|
+
password?: string
|
|
10
|
+
password_confirmation?: string
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
// Validate
|
|
14
|
+
if (!body.current_password) {
|
|
15
|
+
return ctx.json({ message: 'Current password is required.' }, 422)
|
|
16
|
+
}
|
|
17
|
+
if (!body.password || body.password.length < 8) {
|
|
18
|
+
return ctx.json({ message: 'New password must be at least 8 characters.' }, 422)
|
|
19
|
+
}
|
|
20
|
+
if (body.password !== body.password_confirmation) {
|
|
21
|
+
return ctx.json({ message: 'Passwords do not match.' }, 422)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const user = ctx.get('user')
|
|
25
|
+
const hash = JinaManager.actions.passwordHashOf(user)
|
|
26
|
+
const valid = await encrypt.verify(body.current_password, hash)
|
|
27
|
+
|
|
28
|
+
if (!valid) {
|
|
29
|
+
return ctx.json({ message: 'Current password is incorrect.' }, 422)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await JinaManager.actions.updatePassword(user, body.password)
|
|
33
|
+
|
|
34
|
+
if (Emitter.listenerCount(JinaEvents.PASSWORD_UPDATED) > 0) {
|
|
35
|
+
Emitter.emit(JinaEvents.PASSWORD_UPDATED, { user, ctx }).catch(() => {})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return ctx.json({ message: 'Password updated.' })
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Emitter } from '@stravigor/kernel'
|
|
2
|
+
import type { Context } from '@stravigor/http'
|
|
3
|
+
import JinaManager from '../jina_manager.ts'
|
|
4
|
+
import { JinaEvents } 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 JinaManager.actions.updateProfile!(user, data)
|
|
11
|
+
|
|
12
|
+
if (Emitter.listenerCount(JinaEvents.PROFILE_UPDATED) > 0) {
|
|
13
|
+
Emitter.emit(JinaEvents.PROFILE_UPDATED, { user, ctx }).catch(() => {})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return ctx.json({ message: 'Profile updated.' })
|
|
17
|
+
}
|