This commit is contained in:
1
lib/constants/index.ts
Normal file
1
lib/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './passport.constants'
|
||||
1
lib/constants/passport.constants.ts
Normal file
1
lib/constants/passport.constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const PASSPORT_OPTIONS = Symbol('PassportOptions')
|
||||
3
lib/index.ts
Normal file
3
lib/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './interfaces'
|
||||
export * from './passport.module'
|
||||
export * from './passport.service'
|
||||
3
lib/interfaces/index.ts
Normal file
3
lib/interfaces/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './passport-async-options.interface'
|
||||
export * from './passport-options.interface'
|
||||
export * from './token.interface'
|
||||
8
lib/interfaces/passport-async-options.interface.ts
Normal file
8
lib/interfaces/passport-async-options.interface.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { FactoryProvider, ModuleMetadata } from '@nestjs/common'
|
||||
|
||||
import { PassportOptions } from './passport-options.interface'
|
||||
|
||||
export interface PassportAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
||||
useFactory: (...args: any[]) => Promise<PassportOptions> | PassportOptions
|
||||
inject?: FactoryProvider['inject']
|
||||
}
|
||||
3
lib/interfaces/passport-options.interface.ts
Normal file
3
lib/interfaces/passport-options.interface.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface PassportOptions {
|
||||
secretKey: string
|
||||
}
|
||||
13
lib/interfaces/token.interface.ts
Normal file
13
lib/interfaces/token.interface.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface TokenPayload {
|
||||
sub: string
|
||||
}
|
||||
|
||||
// роли храним в токене
|
||||
export interface TokenRolePayload {
|
||||
id: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type VerifyResult =
|
||||
| { valid: true; userId: string; jti: string; role: TokenRolePayload }
|
||||
| { valid: false; reason: string }
|
||||
34
lib/passport.module.ts
Normal file
34
lib/passport.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { type DynamicModule, Global, Module } from '@nestjs/common'
|
||||
|
||||
import { PASSPORT_OPTIONS } from './constants'
|
||||
import type { PassportAsyncOptions, PassportOptions } from './interfaces'
|
||||
import {
|
||||
createPassportAsyncOptionsProvider,
|
||||
createPassportOptionsProvider
|
||||
} from './passport.provider'
|
||||
import { PassportService } from './passport.service'
|
||||
|
||||
@Global()
|
||||
@Module({})
|
||||
export class PassportModule {
|
||||
public static register(options: PassportOptions): DynamicModule {
|
||||
const optionsProvider = createPassportOptionsProvider(options)
|
||||
|
||||
return {
|
||||
module: PassportModule,
|
||||
providers: [optionsProvider, PassportService],
|
||||
exports: [PassportService, PASSPORT_OPTIONS]
|
||||
}
|
||||
}
|
||||
|
||||
public static registerAsync(options: PassportAsyncOptions): DynamicModule {
|
||||
const optionsProvider = createPassportAsyncOptionsProvider(options)
|
||||
|
||||
return {
|
||||
module: PassportModule,
|
||||
imports: options.imports ?? [],
|
||||
providers: [optionsProvider, PassportService],
|
||||
exports: [PassportService, PASSPORT_OPTIONS]
|
||||
}
|
||||
}
|
||||
}
|
||||
33
lib/passport.provider.ts
Normal file
33
lib/passport.provider.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Provider } from '@nestjs/common'
|
||||
|
||||
import { PASSPORT_OPTIONS } from './constants'
|
||||
import type { PassportAsyncOptions, PassportOptions } from './interfaces'
|
||||
|
||||
export function createPassportOptionsProvider(
|
||||
options: PassportOptions
|
||||
): Provider {
|
||||
return {
|
||||
provide: PASSPORT_OPTIONS,
|
||||
useValue: Object.freeze({ ...options })
|
||||
}
|
||||
}
|
||||
|
||||
export function createPassportAsyncOptionsProvider(
|
||||
options: PassportAsyncOptions
|
||||
): Provider {
|
||||
return {
|
||||
provide: PASSPORT_OPTIONS,
|
||||
useFactory: async (...args: any[]) => {
|
||||
const resolved = await options.useFactory!(...args)
|
||||
|
||||
if (!resolved || typeof resolved.secretKey !== 'string') {
|
||||
throw new Error(
|
||||
'[Passport Module] "secretKey" обязателен для заполнения и должен быть строкой.'
|
||||
)
|
||||
}
|
||||
|
||||
return Object.freeze({ ...resolved })
|
||||
},
|
||||
inject: options.inject ?? []
|
||||
}
|
||||
}
|
||||
124
lib/passport.service.ts
Normal file
124
lib/passport.service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Inject, Injectable } from '@nestjs/common'
|
||||
import { createHmac, randomUUID } from 'crypto'
|
||||
|
||||
import { PASSPORT_OPTIONS } from './constants'
|
||||
import { PassportOptions, TokenRolePayload } from './interfaces'
|
||||
import { base64UrlDecode, base64UrlEncode, constantTimeEqual } from './utils'
|
||||
|
||||
const TOKEN_TYPES = {
|
||||
AUTH: 'PassportToken/v1',
|
||||
TEMP_2FA: 'PassportToken/v1/2fa'
|
||||
} as const
|
||||
|
||||
type TokenType = (typeof TOKEN_TYPES)[keyof typeof TOKEN_TYPES]
|
||||
|
||||
@Injectable()
|
||||
export class PassportService {
|
||||
private readonly SECRET_KEY: string
|
||||
|
||||
private static readonly INTERNAL_SEPARATOR = '|'
|
||||
|
||||
public constructor(
|
||||
@Inject(PASSPORT_OPTIONS) private readonly options: PassportOptions
|
||||
) {
|
||||
this.SECRET_KEY = options.secretKey
|
||||
}
|
||||
|
||||
public generate(
|
||||
userId: string,
|
||||
role: TokenRolePayload,
|
||||
ttl: number,
|
||||
tokenType: TokenType = TOKEN_TYPES.AUTH
|
||||
) {
|
||||
const issuedAt = this.now()
|
||||
const expiresAt = issuedAt + ttl
|
||||
|
||||
const jti = randomUUID()
|
||||
|
||||
const userPart = base64UrlEncode(userId)
|
||||
const rolePart = base64UrlEncode(JSON.stringify(role))
|
||||
const iatPart = base64UrlEncode(String(issuedAt))
|
||||
const expPart = base64UrlEncode(String(expiresAt))
|
||||
const jtiPart = base64UrlEncode(jti)
|
||||
|
||||
const serialized = this.serialize(
|
||||
tokenType,
|
||||
userPart,
|
||||
rolePart,
|
||||
iatPart,
|
||||
expPart,
|
||||
jtiPart
|
||||
)
|
||||
const mac = this.computeHmac(this.SECRET_KEY, serialized)
|
||||
|
||||
return `${userPart}.${rolePart}.${iatPart}.${expPart}.${jtiPart}.${mac}`
|
||||
}
|
||||
|
||||
public verify(token: string, expectedType: TokenType = TOKEN_TYPES.AUTH) {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
|
||||
if (parts.length !== 6)
|
||||
return { valid: false, reason: 'Не верный формат токена.' }
|
||||
|
||||
const [userPart, rolePart, iatPart, expPart, jtiPart, mac] = parts
|
||||
|
||||
const serialized = this.serialize(
|
||||
expectedType,
|
||||
userPart,
|
||||
rolePart,
|
||||
iatPart,
|
||||
expPart,
|
||||
jtiPart
|
||||
)
|
||||
|
||||
const expectedMac = this.computeHmac(this.SECRET_KEY, serialized)
|
||||
|
||||
if (!constantTimeEqual(expectedMac, mac))
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Невалидная подпись токена или неверный формат.'
|
||||
}
|
||||
|
||||
const expNumber = Number(base64UrlDecode(expPart))
|
||||
|
||||
if (!Number.isFinite(expNumber))
|
||||
return { valid: false, reason: 'Ошибка формата даты.' }
|
||||
if (this.now() > expNumber)
|
||||
return { valid: false, reason: 'Срок действия токена истёк.' }
|
||||
|
||||
const decodedRole = JSON.parse(
|
||||
base64UrlDecode(rolePart)
|
||||
) as TokenRolePayload
|
||||
return {
|
||||
valid: true,
|
||||
userId: base64UrlDecode(userPart),
|
||||
role: decodedRole,
|
||||
jti: base64UrlDecode(jtiPart)
|
||||
}
|
||||
} catch (error) {
|
||||
return { valid: false, reason: 'Поврежденный токен.' }
|
||||
}
|
||||
}
|
||||
|
||||
private now() {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
private serialize(
|
||||
domain: string,
|
||||
user: string,
|
||||
role: string,
|
||||
iat: string,
|
||||
exp: string,
|
||||
jti: string
|
||||
) {
|
||||
return [domain, user, role, iat, exp, jti].join(
|
||||
PassportService.INTERNAL_SEPARATOR
|
||||
)
|
||||
}
|
||||
|
||||
private computeHmac(secretKey: string, data: string) {
|
||||
return createHmac('sha256', secretKey).update(data).digest('hex')
|
||||
}
|
||||
}
|
||||
17
lib/utils/base64.ts
Normal file
17
lib/utils/base64.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function base64UrlEncode(buf: Buffer | string) {
|
||||
const s = typeof buf === 'string' ? Buffer.from(buf) : buf
|
||||
|
||||
return s
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export function base64UrlDecode(str: string) {
|
||||
str = str.replace(/-/g, '+').replace(/_/g, '/')
|
||||
|
||||
while (str.length % 4) str += '='
|
||||
|
||||
return Buffer.from(str, 'base64').toString()
|
||||
}
|
||||
10
lib/utils/crypto.ts
Normal file
10
lib/utils/crypto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
|
||||
export function constantTimeEqual(a: string, b: string) {
|
||||
const bufA = Buffer.from(a)
|
||||
const bufB = Buffer.from(b)
|
||||
|
||||
if (bufA.length !== bufB.length) return false
|
||||
|
||||
return timingSafeEqual(bufA, bufB)
|
||||
}
|
||||
2
lib/utils/index.ts
Normal file
2
lib/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './base64'
|
||||
export * from './crypto'
|
||||
Reference in New Issue
Block a user