@mantiq/auth 0.2.0 → 0.3.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 +1 -1
- package/src/AuthManager.ts +5 -0
- package/src/AuthServiceProvider.ts +27 -2
- package/src/Authorizable.ts +22 -0
- package/src/HasApiTokens.ts +58 -0
- package/src/authorization/AuthorizationResponse.ts +35 -0
- package/src/authorization/GateManager.ts +247 -0
- package/src/authorization/Policy.ts +18 -0
- package/src/authorization/UserGate.ts +28 -0
- package/src/contracts/AuthConfig.ts +1 -0
- package/src/contracts/NewAccessToken.ts +6 -0
- package/src/guards/TokenGuard.ts +108 -0
- package/src/helpers/gate.ts +22 -0
- package/src/helpers/hash.ts +5 -0
- package/src/index.ts +23 -0
- package/src/middleware/Authorize.ts +41 -0
- package/src/middleware/CheckAbilities.ts +24 -0
- package/src/middleware/CheckForAnyAbility.ts +23 -0
- package/src/migrations/CreatePersonalAccessTokensTable.ts +22 -0
- package/src/models/PersonalAccessToken.ts +28 -0
package/package.json
CHANGED
package/src/AuthManager.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Authenticatable } from './contracts/Authenticatable.ts'
|
|
|
7
7
|
import type { AuthConfig } from './contracts/AuthConfig.ts'
|
|
8
8
|
import { SessionGuard } from './guards/SessionGuard.ts'
|
|
9
9
|
import { RequestGuard } from './guards/RequestGuard.ts'
|
|
10
|
+
import { TokenGuard } from './guards/TokenGuard.ts'
|
|
10
11
|
import { DatabaseUserProvider } from './providers/DatabaseUserProvider.ts'
|
|
11
12
|
|
|
12
13
|
type RequestGuardCallback = (
|
|
@@ -208,6 +209,10 @@ export class AuthManager implements DriverManager<Guard> {
|
|
|
208
209
|
}
|
|
209
210
|
return new SessionGuard(name, provider, encrypter)
|
|
210
211
|
}
|
|
212
|
+
case 'token': {
|
|
213
|
+
const provider = this.createUserProvider(guardConfig.provider)
|
|
214
|
+
return new TokenGuard(name, provider, guardConfig.trackLastUsed ?? false)
|
|
215
|
+
}
|
|
211
216
|
default: {
|
|
212
217
|
throw new Error(
|
|
213
218
|
`Unsupported auth guard driver: "${guardConfig.driver}". ` +
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { ServiceProvider, ConfigRepository } from '@mantiq/core'
|
|
1
|
+
import { ServiceProvider, ConfigRepository, HttpKernel } from '@mantiq/core'
|
|
2
2
|
import { AuthManager } from './AuthManager.ts'
|
|
3
3
|
import { AUTH_MANAGER } from './helpers/auth.ts'
|
|
4
4
|
import { Authenticate } from './middleware/Authenticate.ts'
|
|
5
5
|
import { RedirectIfAuthenticated } from './middleware/RedirectIfAuthenticated.ts'
|
|
6
6
|
import { EnsureEmailIsVerified } from './middleware/EnsureEmailIsVerified.ts'
|
|
7
7
|
import { ConfirmPassword } from './middleware/ConfirmPassword.ts'
|
|
8
|
+
import { Authorize } from './middleware/Authorize.ts'
|
|
9
|
+
import { GateManager } from './authorization/GateManager.ts'
|
|
10
|
+
import { setGateManager, GATE_MANAGER } from './helpers/gate.ts'
|
|
8
11
|
import type { AuthConfig } from './contracts/AuthConfig.ts'
|
|
9
12
|
|
|
10
13
|
const DEFAULT_CONFIG: AuthConfig = {
|
|
@@ -18,7 +21,7 @@ const DEFAULT_CONFIG: AuthConfig = {
|
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
|
-
* Registers authentication bindings in the container.
|
|
24
|
+
* Registers authentication and authorization bindings in the container.
|
|
22
25
|
*
|
|
23
26
|
* Config file: config/auth.ts
|
|
24
27
|
* Required config: guards, providers, defaults.guard
|
|
@@ -32,10 +35,32 @@ export class AuthServiceProvider extends ServiceProvider {
|
|
|
32
35
|
})
|
|
33
36
|
this.app.alias(AuthManager, AUTH_MANAGER)
|
|
34
37
|
|
|
38
|
+
// GateManager — singleton
|
|
39
|
+
this.app.singleton(GateManager, () => {
|
|
40
|
+
const g = new GateManager()
|
|
41
|
+
setGateManager(g)
|
|
42
|
+
return g
|
|
43
|
+
})
|
|
44
|
+
this.app.alias(GateManager, GATE_MANAGER)
|
|
45
|
+
|
|
35
46
|
// Middleware bindings
|
|
36
47
|
this.app.bind(Authenticate, (c) => new Authenticate(c.make(AuthManager)))
|
|
37
48
|
this.app.bind(RedirectIfAuthenticated, (c) => new RedirectIfAuthenticated(c.make(AuthManager)))
|
|
38
49
|
this.app.bind(EnsureEmailIsVerified, () => new EnsureEmailIsVerified())
|
|
39
50
|
this.app.bind(ConfirmPassword, () => new ConfirmPassword())
|
|
51
|
+
this.app.bind(Authorize, () => new Authorize())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override boot(): void {
|
|
55
|
+
// Resolve the GateManager so setGateManager() is called
|
|
56
|
+
this.app.make(GateManager)
|
|
57
|
+
|
|
58
|
+
// Register the 'can' middleware alias
|
|
59
|
+
try {
|
|
60
|
+
const kernel = this.app.make(HttpKernel)
|
|
61
|
+
kernel.registerMiddleware('can', Authorize)
|
|
62
|
+
} catch {
|
|
63
|
+
// HttpKernel may not be available in non-HTTP contexts (e.g., CLI)
|
|
64
|
+
}
|
|
40
65
|
}
|
|
41
66
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { gate } from './helpers/gate.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mixin that adds `can()` / `cannot()` to model instances.
|
|
5
|
+
*
|
|
6
|
+
* Apply to any model class to allow authorization checks directly
|
|
7
|
+
* on the model instance:
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* applyAuthorizable(User)
|
|
11
|
+
* const user = await User.find(1)
|
|
12
|
+
* if (await user.can('edit', post)) { ... }
|
|
13
|
+
*/
|
|
14
|
+
export function applyAuthorizable(ModelClass: any): void {
|
|
15
|
+
ModelClass.prototype.can = async function (ability: string, ...args: any[]): Promise<boolean> {
|
|
16
|
+
return gate().allows(ability, this, ...args)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
ModelClass.prototype.cannot = async function (ability: string, ...args: any[]): Promise<boolean> {
|
|
20
|
+
return gate().denies(ability, this, ...args)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { PersonalAccessToken } from './models/PersonalAccessToken.ts'
|
|
2
|
+
import { sha256 } from './helpers/hash.ts'
|
|
3
|
+
import type { NewAccessToken } from './contracts/NewAccessToken.ts'
|
|
4
|
+
|
|
5
|
+
export function applyHasApiTokens(ModelClass: any): void {
|
|
6
|
+
// Store current access token on instance
|
|
7
|
+
const proto = ModelClass.prototype
|
|
8
|
+
|
|
9
|
+
proto.createToken = async function(name: string, abilities: string[] = ['*'], expiresAt?: Date): Promise<NewAccessToken> {
|
|
10
|
+
// Generate 64 random hex characters
|
|
11
|
+
const randomBytes = new Uint8Array(32)
|
|
12
|
+
crypto.getRandomValues(randomBytes)
|
|
13
|
+
const plaintext = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
14
|
+
|
|
15
|
+
const hash = await sha256(plaintext)
|
|
16
|
+
|
|
17
|
+
const ctor = this.constructor as any
|
|
18
|
+
const token = await PersonalAccessToken.create({
|
|
19
|
+
tokenable_type: ctor.table ?? ctor.name?.toLowerCase() + 's',
|
|
20
|
+
tokenable_id: this.getKey(),
|
|
21
|
+
name,
|
|
22
|
+
token: hash,
|
|
23
|
+
abilities: JSON.stringify(abilities),
|
|
24
|
+
expires_at: expiresAt?.toISOString() ?? null,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const id = token.getKey()
|
|
28
|
+
return {
|
|
29
|
+
accessToken: token,
|
|
30
|
+
plainTextToken: `${id}|${plaintext}`,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
proto.tokens = function() {
|
|
35
|
+
const ctor = this.constructor as any
|
|
36
|
+
return PersonalAccessToken.where('tokenable_type', ctor.table ?? ctor.name?.toLowerCase() + 's')
|
|
37
|
+
.where('tokenable_id', this.getKey())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
proto.currentAccessToken = function(): PersonalAccessToken | null {
|
|
41
|
+
return this._currentAccessToken ?? null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
proto.withAccessToken = function(token: PersonalAccessToken): any {
|
|
45
|
+
this._currentAccessToken = token
|
|
46
|
+
return this
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
proto.tokenCan = function(ability: string): boolean {
|
|
50
|
+
const token = this.currentAccessToken()
|
|
51
|
+
if (!token) return false
|
|
52
|
+
return token.can(ability)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
proto.tokenCant = function(ability: string): boolean {
|
|
56
|
+
return !this.tokenCan(ability)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export class AuthorizationResponse {
|
|
2
|
+
private _allowed: boolean
|
|
3
|
+
private _message: string | null
|
|
4
|
+
private _code: number | null
|
|
5
|
+
|
|
6
|
+
constructor(allowed: boolean, message?: string, code?: number) {
|
|
7
|
+
this._allowed = allowed
|
|
8
|
+
this._message = message ?? null
|
|
9
|
+
this._code = code ?? null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
allowed(): boolean {
|
|
13
|
+
return this._allowed
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
denied(): boolean {
|
|
17
|
+
return !this._allowed
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
message(): string | null {
|
|
21
|
+
return this._message
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
code(): number | null {
|
|
25
|
+
return this._code
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static allow(message?: string): AuthorizationResponse {
|
|
29
|
+
return new AuthorizationResponse(true, message)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static deny(message?: string, code?: number): AuthorizationResponse {
|
|
33
|
+
return new AuthorizationResponse(false, message ?? 'This action is unauthorized.', code ?? 403)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { ForbiddenError } from '@mantiq/core'
|
|
2
|
+
import { AuthorizationResponse } from './AuthorizationResponse.ts'
|
|
3
|
+
import type { Policy } from './Policy.ts'
|
|
4
|
+
import { UserGate } from './UserGate.ts'
|
|
5
|
+
|
|
6
|
+
type GateCallback = (user: any, ...args: any[]) => boolean | AuthorizationResponse | Promise<boolean | AuthorizationResponse>
|
|
7
|
+
type BeforeCallback = (user: any, ability: string, ...args: any[]) => boolean | null | undefined | Promise<boolean | null | undefined>
|
|
8
|
+
type AfterCallback = (user: any, ability: string, result: boolean, ...args: any[]) => void | Promise<void>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Central authorization manager — Laravel-style Gates & Policies.
|
|
12
|
+
*
|
|
13
|
+
* Gate closures handle simple ability checks.
|
|
14
|
+
* Policies handle model-based authorization — each policy maps to a model class.
|
|
15
|
+
*
|
|
16
|
+
* Resolution order:
|
|
17
|
+
* 1. Global `before` callbacks (short-circuit on non-null)
|
|
18
|
+
* 2. Policy `before()` hook (if a policy matches)
|
|
19
|
+
* 3. Policy method matching the ability name
|
|
20
|
+
* 4. Gate closure matching the ability name
|
|
21
|
+
* 5. Deny by default
|
|
22
|
+
* 6. Global `after` callbacks (for auditing, cannot change result)
|
|
23
|
+
*/
|
|
24
|
+
export class GateManager {
|
|
25
|
+
private gates = new Map<string, GateCallback>()
|
|
26
|
+
private policies = new Map<any, new () => Policy>()
|
|
27
|
+
private beforeCallbacks: BeforeCallback[] = []
|
|
28
|
+
private afterCallbacks: AfterCallback[] = []
|
|
29
|
+
|
|
30
|
+
// ── Registration ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Define a gate closure for the given ability.
|
|
34
|
+
*/
|
|
35
|
+
define(ability: string, callback: GateCallback): this {
|
|
36
|
+
this.gates.set(ability, callback)
|
|
37
|
+
return this
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register a policy class for a model constructor.
|
|
42
|
+
*/
|
|
43
|
+
policy(modelClass: any, policyClass: new () => Policy): this {
|
|
44
|
+
this.policies.set(modelClass, policyClass)
|
|
45
|
+
return this
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register a global before callback.
|
|
50
|
+
* If the callback returns `true` or `false`, the check short-circuits.
|
|
51
|
+
* Return `null` or `undefined` to continue to the gate/policy check.
|
|
52
|
+
*/
|
|
53
|
+
before(callback: BeforeCallback): this {
|
|
54
|
+
this.beforeCallbacks.push(callback)
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register a global after callback (for logging/auditing).
|
|
60
|
+
* After callbacks cannot change the authorization result.
|
|
61
|
+
*/
|
|
62
|
+
after(callback: AfterCallback): this {
|
|
63
|
+
this.afterCallbacks.push(callback)
|
|
64
|
+
return this
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Checking ─────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if the given ability is allowed for the user.
|
|
71
|
+
*/
|
|
72
|
+
async allows(ability: string, user: any, ...args: any[]): Promise<boolean> {
|
|
73
|
+
const response = await this.resolve(ability, user, ...args)
|
|
74
|
+
return response.allowed()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if the given ability is denied for the user.
|
|
79
|
+
*/
|
|
80
|
+
async denies(ability: string, user: any, ...args: any[]): Promise<boolean> {
|
|
81
|
+
return !(await this.allows(ability, user, ...args))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Authorize the ability or throw ForbiddenError.
|
|
86
|
+
* Returns the AuthorizationResponse if allowed.
|
|
87
|
+
*/
|
|
88
|
+
async authorize(ability: string, user: any, ...args: any[]): Promise<AuthorizationResponse> {
|
|
89
|
+
const response = await this.resolve(ability, user, ...args)
|
|
90
|
+
|
|
91
|
+
if (response.denied()) {
|
|
92
|
+
throw new ForbiddenError(response.message() ?? 'This action is unauthorized.')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return response
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check multiple abilities — all must pass.
|
|
100
|
+
*/
|
|
101
|
+
async check(abilities: string[], user: any, ...args: any[]): Promise<boolean> {
|
|
102
|
+
for (const ability of abilities) {
|
|
103
|
+
if (!(await this.allows(ability, user, ...args))) {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check multiple abilities — at least one must pass.
|
|
112
|
+
*/
|
|
113
|
+
async any(abilities: string[], user: any, ...args: any[]): Promise<boolean> {
|
|
114
|
+
for (const ability of abilities) {
|
|
115
|
+
if (await this.allows(ability, user, ...args)) {
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns a UserGate scoped to a specific user for convenience.
|
|
124
|
+
*/
|
|
125
|
+
forUser(user: any): UserGate {
|
|
126
|
+
return new UserGate(this, user)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Policy resolution ──────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the policy class for a given model instance.
|
|
133
|
+
* Returns null if no policy is registered for this model's constructor.
|
|
134
|
+
*/
|
|
135
|
+
getPolicyFor(model: any): Policy | null {
|
|
136
|
+
if (model === null || model === undefined) return null
|
|
137
|
+
|
|
138
|
+
const constructor = model.constructor
|
|
139
|
+
const PolicyClass = this.policies.get(constructor)
|
|
140
|
+
|
|
141
|
+
if (!PolicyClass) return null
|
|
142
|
+
return new PolicyClass()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Internal resolution ────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Core resolution logic.
|
|
149
|
+
*/
|
|
150
|
+
private async resolve(ability: string, user: any, ...args: any[]): Promise<AuthorizationResponse> {
|
|
151
|
+
// 1. Run global before callbacks
|
|
152
|
+
for (const cb of this.beforeCallbacks) {
|
|
153
|
+
const result = await cb(user, ability, ...args)
|
|
154
|
+
if (result === true) {
|
|
155
|
+
const response = AuthorizationResponse.allow()
|
|
156
|
+
await this.runAfterCallbacks(user, ability, true, ...args)
|
|
157
|
+
return response
|
|
158
|
+
}
|
|
159
|
+
if (result === false) {
|
|
160
|
+
const response = AuthorizationResponse.deny()
|
|
161
|
+
await this.runAfterCallbacks(user, ability, false, ...args)
|
|
162
|
+
return response
|
|
163
|
+
}
|
|
164
|
+
// null/undefined → continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. Try policy-based authorization
|
|
168
|
+
const firstArg = args[0]
|
|
169
|
+
const policyResult = await this.tryPolicy(ability, user, firstArg, ...args)
|
|
170
|
+
if (policyResult !== null) {
|
|
171
|
+
const allowed = policyResult.allowed()
|
|
172
|
+
await this.runAfterCallbacks(user, ability, allowed, ...args)
|
|
173
|
+
return policyResult
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 3. Try gate closure
|
|
177
|
+
const gateResult = await this.tryGate(ability, user, ...args)
|
|
178
|
+
if (gateResult !== null) {
|
|
179
|
+
const allowed = gateResult.allowed()
|
|
180
|
+
await this.runAfterCallbacks(user, ability, allowed, ...args)
|
|
181
|
+
return gateResult
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 4. Deny by default
|
|
185
|
+
const denied = AuthorizationResponse.deny()
|
|
186
|
+
await this.runAfterCallbacks(user, ability, false, ...args)
|
|
187
|
+
return denied
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Attempt policy-based authorization.
|
|
192
|
+
* Returns null if no matching policy or method.
|
|
193
|
+
*/
|
|
194
|
+
private async tryPolicy(ability: string, user: any, firstArg: any, ...allArgs: any[]): Promise<AuthorizationResponse | null> {
|
|
195
|
+
if (firstArg === null || firstArg === undefined) return null
|
|
196
|
+
|
|
197
|
+
const policy = this.getPolicyFor(firstArg)
|
|
198
|
+
if (!policy) return null
|
|
199
|
+
|
|
200
|
+
// Policy before() hook
|
|
201
|
+
if (typeof policy.before === 'function') {
|
|
202
|
+
const beforeResult = await policy.before(user, ability, ...allArgs)
|
|
203
|
+
if (beforeResult === true) return AuthorizationResponse.allow()
|
|
204
|
+
if (beforeResult === false) return AuthorizationResponse.deny()
|
|
205
|
+
// null/undefined → continue to method
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Check if the policy has a method matching the ability name
|
|
209
|
+
const method = (policy as any)[ability]
|
|
210
|
+
if (typeof method !== 'function') {
|
|
211
|
+
// Policy exists but no matching method → deny
|
|
212
|
+
return AuthorizationResponse.deny(`Policy method "${ability}" not defined.`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const result = await method.call(policy, user, ...allArgs)
|
|
216
|
+
return this.normalizeResult(result)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Attempt gate closure authorization.
|
|
221
|
+
* Returns null if no gate is defined for this ability.
|
|
222
|
+
*/
|
|
223
|
+
private async tryGate(ability: string, user: any, ...args: any[]): Promise<AuthorizationResponse | null> {
|
|
224
|
+
const callback = this.gates.get(ability)
|
|
225
|
+
if (!callback) return null
|
|
226
|
+
|
|
227
|
+
const result = await callback(user, ...args)
|
|
228
|
+
return this.normalizeResult(result)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Normalize a boolean or AuthorizationResponse return value.
|
|
233
|
+
*/
|
|
234
|
+
private normalizeResult(result: boolean | AuthorizationResponse): AuthorizationResponse {
|
|
235
|
+
if (result instanceof AuthorizationResponse) return result
|
|
236
|
+
return result ? AuthorizationResponse.allow() : AuthorizationResponse.deny()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Run all global after callbacks.
|
|
241
|
+
*/
|
|
242
|
+
private async runAfterCallbacks(user: any, ability: string, result: boolean, ...args: any[]): Promise<void> {
|
|
243
|
+
for (const cb of this.afterCallbacks) {
|
|
244
|
+
await cb(user, ability, result, ...args)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for authorization policies.
|
|
3
|
+
*
|
|
4
|
+
* Subclass and define methods matching ability names.
|
|
5
|
+
* Each method receives the user + optional model arguments and returns
|
|
6
|
+
* a boolean or AuthorizationResponse.
|
|
7
|
+
*
|
|
8
|
+
* The optional `before()` hook is called before any policy method and
|
|
9
|
+
* can short-circuit the check: return `true` to allow, `false` to deny,
|
|
10
|
+
* or `null`/`undefined` to continue to the actual policy method.
|
|
11
|
+
*/
|
|
12
|
+
export abstract class Policy {
|
|
13
|
+
/**
|
|
14
|
+
* Called before any policy method.
|
|
15
|
+
* Return true to allow, false to deny, null/undefined to continue.
|
|
16
|
+
*/
|
|
17
|
+
before?(user: any, ability: string, ...args: any[]): boolean | null | undefined | Promise<boolean | null | undefined>
|
|
18
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GateManager } from './GateManager.ts'
|
|
2
|
+
import type { AuthorizationResponse } from './AuthorizationResponse.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A gate scoped to a specific user for convenience.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const userGate = gate().forUser(user)
|
|
9
|
+
* if (await userGate.can('edit', post)) { ... }
|
|
10
|
+
*/
|
|
11
|
+
export class UserGate {
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly gate: GateManager,
|
|
14
|
+
private readonly user: any,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
async can(ability: string, ...args: any[]): Promise<boolean> {
|
|
18
|
+
return this.gate.allows(ability, this.user, ...args)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async cannot(ability: string, ...args: any[]): Promise<boolean> {
|
|
22
|
+
return this.gate.denies(ability, this.user, ...args)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async authorize(ability: string, ...args: any[]): Promise<AuthorizationResponse> {
|
|
26
|
+
return this.gate.authorize(ability, this.user, ...args)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { Constructor } from '@mantiq/core'
|
|
|
3
3
|
export interface GuardConfig {
|
|
4
4
|
driver: string // 'session' | custom driver name
|
|
5
5
|
provider: string // Name referencing a provider in config.providers
|
|
6
|
+
trackLastUsed?: boolean | undefined
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export interface ProviderConfig {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Guard } from '../contracts/Guard.ts'
|
|
2
|
+
import type { Authenticatable } from '../contracts/Authenticatable.ts'
|
|
3
|
+
import type { UserProvider } from '../contracts/UserProvider.ts'
|
|
4
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
5
|
+
import { PersonalAccessToken } from '../models/PersonalAccessToken.ts'
|
|
6
|
+
import { sha256 } from '../helpers/hash.ts'
|
|
7
|
+
|
|
8
|
+
export class TokenGuard implements Guard {
|
|
9
|
+
private _user: Authenticatable | null = null
|
|
10
|
+
private _request: MantiqRequest | null = null
|
|
11
|
+
private _resolved = false
|
|
12
|
+
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly name: string,
|
|
15
|
+
private readonly provider: UserProvider,
|
|
16
|
+
private readonly trackLastUsed = false,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async check(): Promise<boolean> {
|
|
20
|
+
return (await this.user()) !== null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async guest(): Promise<boolean> {
|
|
24
|
+
return !(await this.check())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async user(): Promise<Authenticatable | null> {
|
|
28
|
+
if (this._resolved) return this._user
|
|
29
|
+
this._resolved = true
|
|
30
|
+
|
|
31
|
+
if (!this._request) return null
|
|
32
|
+
|
|
33
|
+
const bearerToken = this._request.bearerToken()
|
|
34
|
+
if (!bearerToken) return null
|
|
35
|
+
|
|
36
|
+
const token = await this.resolveToken(bearerToken)
|
|
37
|
+
if (!token) return null
|
|
38
|
+
|
|
39
|
+
// Check expiration
|
|
40
|
+
if (token.isExpired()) return null
|
|
41
|
+
|
|
42
|
+
// Optionally track last usage (disabled by default to avoid write-per-request)
|
|
43
|
+
if (this.trackLastUsed) {
|
|
44
|
+
token.setAttribute('last_used_at', new Date().toISOString())
|
|
45
|
+
token.save().catch(() => {})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve user
|
|
49
|
+
const userId = token.getAttribute('tokenable_id')
|
|
50
|
+
const user = await this.provider.retrieveById(userId)
|
|
51
|
+
if (!user) return null
|
|
52
|
+
|
|
53
|
+
// Attach token to user if it supports it
|
|
54
|
+
if (typeof (user as any).withAccessToken === 'function') {
|
|
55
|
+
(user as any).withAccessToken(token)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this._user = user
|
|
59
|
+
return user
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async id(): Promise<string | number | null> {
|
|
63
|
+
const user = await this.user()
|
|
64
|
+
return user?.getAuthIdentifier() ?? null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async validate(credentials: Record<string, any>): Promise<boolean> {
|
|
68
|
+
const user = await this.provider.retrieveByCredentials(credentials)
|
|
69
|
+
if (!user) return false
|
|
70
|
+
return this.provider.validateCredentials(user, credentials)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setUser(user: Authenticatable): void {
|
|
74
|
+
this._user = user
|
|
75
|
+
this._resolved = true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
hasUser(): boolean {
|
|
79
|
+
return this._user !== null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setRequest(request: MantiqRequest): void {
|
|
83
|
+
this._request = request
|
|
84
|
+
this._user = null
|
|
85
|
+
this._resolved = false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async resolveToken(bearerToken: string): Promise<PersonalAccessToken | null> {
|
|
89
|
+
const parts = bearerToken.split('|')
|
|
90
|
+
|
|
91
|
+
if (parts.length === 2) {
|
|
92
|
+
// Format: {id}|{plaintext}
|
|
93
|
+
const [id, plaintext] = parts
|
|
94
|
+
const token = await PersonalAccessToken.find(Number(id))
|
|
95
|
+
if (!token) return null
|
|
96
|
+
|
|
97
|
+
const hash = await sha256(plaintext!)
|
|
98
|
+
const storedHash = token.getAttribute('token') as string
|
|
99
|
+
if (hash !== storedHash) return null
|
|
100
|
+
|
|
101
|
+
return token
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Fallback: hash the entire string and search by token hash
|
|
105
|
+
const hash = await sha256(bearerToken)
|
|
106
|
+
return await PersonalAccessToken.where('token', hash).first() as PersonalAccessToken | null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { GateManager } from '../authorization/GateManager.ts'
|
|
2
|
+
|
|
3
|
+
let _gate: GateManager | null = null
|
|
4
|
+
|
|
5
|
+
export const GATE_MANAGER = Symbol('GateManager')
|
|
6
|
+
|
|
7
|
+
export function setGateManager(g: GateManager): void {
|
|
8
|
+
_gate = g
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Access the gate manager singleton.
|
|
13
|
+
*
|
|
14
|
+
* @example gate().allows('edit-post', user, post)
|
|
15
|
+
* @example gate().forUser(user).can('edit-post', post)
|
|
16
|
+
*/
|
|
17
|
+
export function gate(): GateManager {
|
|
18
|
+
if (!_gate) {
|
|
19
|
+
throw new Error('Gate manager not initialized. Register AuthServiceProvider.')
|
|
20
|
+
}
|
|
21
|
+
return _gate
|
|
22
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export async function sha256(input: string): Promise<string> {
|
|
2
|
+
const data = new TextEncoder().encode(input)
|
|
3
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
4
|
+
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
5
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,14 +4,22 @@ export type { Guard } from './contracts/Guard.ts'
|
|
|
4
4
|
export type { StatefulGuard } from './contracts/StatefulGuard.ts'
|
|
5
5
|
export type { UserProvider } from './contracts/UserProvider.ts'
|
|
6
6
|
export type { AuthConfig, GuardConfig, ProviderConfig } from './contracts/AuthConfig.ts'
|
|
7
|
+
export type { NewAccessToken } from './contracts/NewAccessToken.ts'
|
|
7
8
|
|
|
8
9
|
// ── Core ──────────────────────────────────────────────────────────────────────
|
|
9
10
|
export { AuthManager } from './AuthManager.ts'
|
|
10
11
|
export { AuthServiceProvider } from './AuthServiceProvider.ts'
|
|
11
12
|
|
|
13
|
+
// ── Authorization (Gates & Policies) ─────────────────────────────────────────
|
|
14
|
+
export { GateManager } from './authorization/GateManager.ts'
|
|
15
|
+
export { AuthorizationResponse } from './authorization/AuthorizationResponse.ts'
|
|
16
|
+
export { Policy } from './authorization/Policy.ts'
|
|
17
|
+
export { UserGate } from './authorization/UserGate.ts'
|
|
18
|
+
|
|
12
19
|
// ── Guards ────────────────────────────────────────────────────────────────────
|
|
13
20
|
export { SessionGuard } from './guards/SessionGuard.ts'
|
|
14
21
|
export { RequestGuard } from './guards/RequestGuard.ts'
|
|
22
|
+
export { TokenGuard } from './guards/TokenGuard.ts'
|
|
15
23
|
|
|
16
24
|
// ── Providers ─────────────────────────────────────────────────────────────────
|
|
17
25
|
export { DatabaseUserProvider } from './providers/DatabaseUserProvider.ts'
|
|
@@ -21,6 +29,9 @@ export { Authenticate } from './middleware/Authenticate.ts'
|
|
|
21
29
|
export { RedirectIfAuthenticated } from './middleware/RedirectIfAuthenticated.ts'
|
|
22
30
|
export { EnsureEmailIsVerified } from './middleware/EnsureEmailIsVerified.ts'
|
|
23
31
|
export { ConfirmPassword } from './middleware/ConfirmPassword.ts'
|
|
32
|
+
export { CheckAbilities } from './middleware/CheckAbilities.ts'
|
|
33
|
+
export { CheckForAnyAbility } from './middleware/CheckForAnyAbility.ts'
|
|
34
|
+
export { Authorize } from './middleware/Authorize.ts'
|
|
24
35
|
|
|
25
36
|
// ── Errors ────────────────────────────────────────────────────────────────────
|
|
26
37
|
export { AuthenticationError } from './errors/AuthenticationError.ts'
|
|
@@ -28,5 +39,17 @@ export { AuthenticationError } from './errors/AuthenticationError.ts'
|
|
|
28
39
|
// ── Events ────────────────────────────────────────────────────────────────────
|
|
29
40
|
export { Attempting, Authenticated, Login, Failed, Logout, Registered, Lockout } from './events/AuthEvents.ts'
|
|
30
41
|
|
|
42
|
+
// ── Models ────────────────────────────────────────────────────────────────────
|
|
43
|
+
export { PersonalAccessToken } from './models/PersonalAccessToken.ts'
|
|
44
|
+
|
|
45
|
+
// ── Mixins ────────────────────────────────────────────────────────────────────
|
|
46
|
+
export { applyHasApiTokens } from './HasApiTokens.ts'
|
|
47
|
+
export { applyAuthorizable } from './Authorizable.ts'
|
|
48
|
+
|
|
49
|
+
// ── Migrations ────────────────────────────────────────────────────────────────
|
|
50
|
+
export { CreatePersonalAccessTokensTable } from './migrations/CreatePersonalAccessTokensTable.ts'
|
|
51
|
+
|
|
31
52
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
53
|
+
export { sha256 } from './helpers/hash.ts'
|
|
32
54
|
export { auth, AUTH_MANAGER } from './helpers/auth.ts'
|
|
55
|
+
export { gate, setGateManager, GATE_MANAGER } from './helpers/gate.ts'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Middleware, NextFunction, MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import { gate } from '../helpers/gate.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Authorization middleware for routes.
|
|
6
|
+
*
|
|
7
|
+
* Checks that the authenticated user is authorized for the given ability.
|
|
8
|
+
* Returns 401 if unauthenticated, or throws ForbiddenError (via gate().authorize())
|
|
9
|
+
* if the user is not authorized.
|
|
10
|
+
*
|
|
11
|
+
* Usage: `.middleware('can:update,post')`
|
|
12
|
+
*
|
|
13
|
+
* - params[0] = ability name
|
|
14
|
+
* - params[1..] = optional extra arguments passed to the gate/policy
|
|
15
|
+
*/
|
|
16
|
+
export class Authorize implements Middleware {
|
|
17
|
+
private params: string[] = []
|
|
18
|
+
|
|
19
|
+
setParameters(params: string[]): void {
|
|
20
|
+
this.params = params
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
|
|
24
|
+
const user = request.user()
|
|
25
|
+
if (!user) {
|
|
26
|
+
return new Response(
|
|
27
|
+
JSON.stringify({ message: 'Unauthenticated.' }),
|
|
28
|
+
{ status: 401, headers: { 'Content-Type': 'application/json' } },
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ability = this.params[0]
|
|
33
|
+
if (!ability) return next()
|
|
34
|
+
|
|
35
|
+
const gateManager = gate()
|
|
36
|
+
// authorize() throws ForbiddenError if denied
|
|
37
|
+
await gateManager.authorize(ability, user, ...this.params.slice(1))
|
|
38
|
+
|
|
39
|
+
return next()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
2
|
+
|
|
3
|
+
export class CheckAbilities {
|
|
4
|
+
private abilities: string[] = []
|
|
5
|
+
|
|
6
|
+
setParameters(...abilities: string[]): void {
|
|
7
|
+
this.abilities = abilities
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async handle(request: MantiqRequest, next: () => Promise<Response>): Promise<Response> {
|
|
11
|
+
const user = request.user<any>()
|
|
12
|
+
if (!user || typeof user.tokenCan !== 'function') {
|
|
13
|
+
return new Response(JSON.stringify({ message: 'Unauthorized.' }), { status: 403, headers: { 'Content-Type': 'application/json' } })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
for (const ability of this.abilities) {
|
|
17
|
+
if (!user.tokenCan(ability)) {
|
|
18
|
+
return new Response(JSON.stringify({ message: `Missing ability: ${ability}` }), { status: 403, headers: { 'Content-Type': 'application/json' } })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return next()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
2
|
+
|
|
3
|
+
export class CheckForAnyAbility {
|
|
4
|
+
private abilities: string[] = []
|
|
5
|
+
|
|
6
|
+
setParameters(...abilities: string[]): void {
|
|
7
|
+
this.abilities = abilities
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async handle(request: MantiqRequest, next: () => Promise<Response>): Promise<Response> {
|
|
11
|
+
const user = request.user<any>()
|
|
12
|
+
if (!user || typeof user.tokenCan !== 'function') {
|
|
13
|
+
return new Response(JSON.stringify({ message: 'Unauthorized.' }), { status: 403, headers: { 'Content-Type': 'application/json' } })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const hasAny = this.abilities.some(ability => user.tokenCan(ability))
|
|
17
|
+
if (!hasAny) {
|
|
18
|
+
return new Response(JSON.stringify({ message: 'Insufficient abilities.' }), { status: 403, headers: { 'Content-Type': 'application/json' } })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return next()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Migration } from '@mantiq/database'
|
|
2
|
+
import type { SchemaBuilder } from '@mantiq/database'
|
|
3
|
+
|
|
4
|
+
export class CreatePersonalAccessTokensTable extends Migration {
|
|
5
|
+
override async up(schema: SchemaBuilder): Promise<void> {
|
|
6
|
+
await schema.create('personal_access_tokens', (table) => {
|
|
7
|
+
table.id()
|
|
8
|
+
table.string('tokenable_type')
|
|
9
|
+
table.unsignedBigInteger('tokenable_id')
|
|
10
|
+
table.string('name')
|
|
11
|
+
table.string('token', 64).unique()
|
|
12
|
+
table.json('abilities').nullable()
|
|
13
|
+
table.timestamp('last_used_at').nullable()
|
|
14
|
+
table.timestamp('expires_at').nullable()
|
|
15
|
+
table.timestamps()
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override async down(schema: SchemaBuilder): Promise<void> {
|
|
20
|
+
await schema.dropIfExists('personal_access_tokens')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Model } from '@mantiq/database'
|
|
2
|
+
|
|
3
|
+
export class PersonalAccessToken extends Model {
|
|
4
|
+
static override table = 'personal_access_tokens'
|
|
5
|
+
static override fillable = ['name', 'token', 'abilities', 'expires_at', 'last_used_at', 'tokenable_type', 'tokenable_id']
|
|
6
|
+
static override hidden = ['token']
|
|
7
|
+
static override casts = {
|
|
8
|
+
abilities: 'json' as const,
|
|
9
|
+
last_used_at: 'datetime' as const,
|
|
10
|
+
expires_at: 'datetime' as const,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
can(ability: string): boolean {
|
|
14
|
+
const abilities = this.getAttribute('abilities') as string[] | null
|
|
15
|
+
if (!abilities) return false
|
|
16
|
+
return abilities.includes('*') || abilities.includes(ability)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
cant(ability: string): boolean {
|
|
20
|
+
return !this.can(ability)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
isExpired(): boolean {
|
|
24
|
+
const expiresAt = this.getAttribute('expires_at')
|
|
25
|
+
if (!expiresAt) return false
|
|
26
|
+
return new Date(expiresAt) < new Date()
|
|
27
|
+
}
|
|
28
|
+
}
|