125 lines
3.1 KiB
TypeScript
125 lines
3.1 KiB
TypeScript
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')
|
||
}
|
||
}
|