mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-10-26 09:33:41 +00:00
Compare commits
19 Commits
v2.11.0
...
feat/jwt-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b226de42 | ||
|
|
9f2874b23d | ||
|
|
b0d9fbbbaf | ||
|
|
7c100f4030 | ||
|
|
8d0a4273fb | ||
|
|
bb0eca80a7 | ||
|
|
3e4a64551f | ||
|
|
94698cea50 | ||
|
|
8294cd68da | ||
|
|
7c9b8ac178 | ||
|
|
5d8f46abf8 | ||
|
|
35a3760771 | ||
|
|
4ef25887b9 | ||
|
|
7f229959d6 | ||
|
|
d3a2936979 | ||
|
|
5f16885923 | ||
|
|
ea5e7a7fc7 | ||
|
|
7de6c86f9e | ||
|
|
83da6b7ee9 |
@@ -29,6 +29,7 @@ module.exports = {
|
||||
{
|
||||
js: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -2,6 +2,53 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
## [2.13.0](https://github.com/CorentinTh/it-tools/compare/v2.11.0...v2.13.0) (2022-11-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **config:** added tsx to allowed extension ([ea5e7a7](https://github.com/CorentinTh/it-tools/commit/ea5e7a7fc7df1a3a912193912a6ab80a8a36a256))
|
||||
* **date-converter:** added mongodb objectID format ([4ef2588](https://github.com/CorentinTh/it-tools/commit/4ef25887b9d874b8789bf8dbabd8aab92b4b1b03))
|
||||
* **new-tool:** added otp generator ([5f16885](https://github.com/CorentinTh/it-tools/commit/5f168859238e9c3a8b8bbaf6b550c4b9bd163e00))
|
||||
* **new-tool:** mime type to extension converter ([7c9b8ac](https://github.com/CorentinTh/it-tools/commit/7c9b8ac178967151a4f921ac26e8c2fe8d23b886))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ui:** remove icon transparency overlap ([35a3760](https://github.com/CorentinTh/it-tools/commit/35a376077116dd65b21f9a0786d2ecfc14db6051))
|
||||
|
||||
|
||||
### Refactors
|
||||
|
||||
* **otp-generator:** changed url ([7f22995](https://github.com/CorentinTh/it-tools/commit/7f229959d64b7a932f32753e3838d87a819a9192))
|
||||
* token generator can use a custom alphabet ([83da6b7](https://github.com/CorentinTh/it-tools/commit/83da6b7ee9db29e40faf288f9627257aa7124038))
|
||||
* **ui:** change sponsor button location and caption ([5d8f46a](https://github.com/CorentinTh/it-tools/commit/5d8f46abf8d5a10cc4650efc87b12a9a6c537fe5))
|
||||
* **useQRCode:** switched args to MaybeRef ([7de6c86](https://github.com/CorentinTh/it-tools/commit/7de6c86f9ead8d7315614cc508dfee4fed90e9c2))
|
||||
|
||||
## [2.12.0](https://github.com/CorentinTh/it-tools/compare/v2.10.3...v2.12.0) (2022-08-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* added colored share card ([ab7483b](https://github.com/CorentinTh/it-tools/commit/ab7483b5c2bd5aee1b8b609597c22b7b7b55606d))
|
||||
* **config:** added tsx to allowed extension ([741a3c2](https://github.com/CorentinTh/it-tools/commit/741a3c25a915d8296987b23bda03f2b664d51ba6))
|
||||
* **new-tool:** added otp generator ([cc6070a](https://github.com/CorentinTh/it-tools/commit/cc6070a16655bce9de90517bdda3bf6224ba139d))
|
||||
* **new-tool:** meta tag generator ([164e32b](https://github.com/CorentinTh/it-tools/commit/164e32b4428b8dfaaddcefa06b767a8af94573a9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** added missing optional deps ([4975590](https://github.com/CorentinTh/it-tools/commit/49755909bdaea9399e51b67fbd1a6d071acd3182))
|
||||
* removed colored card border ([7c449f4](https://github.com/CorentinTh/it-tools/commit/7c449f4f2d491ce58726c5419a74dc295fa92905))
|
||||
|
||||
|
||||
### Refactors
|
||||
|
||||
* **colored-card:** added transition on like hover ([da17696](https://github.com/CorentinTh/it-tools/commit/da17696293270005b1b7ec4aafc0df7496f602c7))
|
||||
* **share:** updated share meta ([5222bd5](https://github.com/CorentinTh/it-tools/commit/5222bd5d04ad089ba4cbade399dada55e29dcde5))
|
||||
* token generator can use a custom alphabet ([59ec629](https://github.com/CorentinTh/it-tools/commit/59ec6293b65526fe8dc527ac596d0e5af29b1e32))
|
||||
* **useQRCode:** switched args to MaybeRef ([a89c9be](https://github.com/CorentinTh/it-tools/commit/a89c9bea42d598f4caba10800becd66a07bbcdc9))
|
||||
|
||||
## [2.11.0](https://github.com/CorentinTh/it-tools/compare/v2.10.3...v2.11.0) (2022-08-19)
|
||||
|
||||
|
||||
|
||||
17251
package-lock.json
generated
Normal file
17251
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "it-tools",
|
||||
"version": "2.11.0",
|
||||
"version": "2.13.0",
|
||||
"description": "Collection of handy online tools for developers, with great UX. ",
|
||||
"keywords": [
|
||||
"productivity",
|
||||
@@ -36,52 +36,53 @@
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vicons/tabler": "^0.12.0",
|
||||
"@vueuse/core": "^8.9.4",
|
||||
"@vueuse/head": "^0.7.9",
|
||||
"@vueuse/head": "^0.7.13",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"change-case": "^4.1.2",
|
||||
"colord": "^2.9.3",
|
||||
"cron-validator": "^1.3.1",
|
||||
"cronstrue": "^2.11.0",
|
||||
"cronstrue": "^2.15.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"date-fns": "^2.29.1",
|
||||
"date-fns": "^2.29.3",
|
||||
"figue": "^1.2.0",
|
||||
"highlight.js": "^11.6.0",
|
||||
"json5": "^2.2.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"mathjs": "^10.6.4",
|
||||
"mime-types": "^2.1.35",
|
||||
"naive-ui": "^2.32.1",
|
||||
"pinia": "^2.0.18",
|
||||
"naive-ui": "^2.33.5",
|
||||
"pinia": "^2.0.23",
|
||||
"plausible-tracker": "^0.3.8",
|
||||
"qrcode": "^1.5.1",
|
||||
"randombytes": "^2.1.0",
|
||||
"sql-formatter": "^8.2.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vue": "^3.2.37",
|
||||
"vue-router": "^4.1.3"
|
||||
"vue": "^3.2.45",
|
||||
"vue-router": "^4.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.1.4",
|
||||
"@rushstack/eslint-patch": "^1.2.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/jsdom": "^16.2.15",
|
||||
"@types/lodash": "^4.14.183",
|
||||
"@types/lodash": "^4.14.188",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "^16.11.49",
|
||||
"@types/qrcode": "^1.4.3",
|
||||
"@types/node": "^16.18.3",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/randombytes": "^2.0.0",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/parser": "^5.33.1",
|
||||
"@typescript-eslint/parser": "^5.42.1",
|
||||
"@vitejs/plugin-vue": "^2.3.4",
|
||||
"@vitejs/plugin-vue-jsx": "^1.3.10",
|
||||
"@vue/eslint-config-prettier": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^10.0.0",
|
||||
"@vue/test-utils": "^2.0.2",
|
||||
"@vue/test-utils": "^2.2.2",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"c8": "^7.12.0",
|
||||
"eslint": "^8.22.0",
|
||||
"eslint": "^8.27.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^3.4.2",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-vue": "^8.7.1",
|
||||
"jsdom": "^19.0.0",
|
||||
@@ -93,7 +94,7 @@
|
||||
"vite": "^2.9.15",
|
||||
"vite-plugin-md": "^0.12.4",
|
||||
"vite-plugin-pwa": "^0.11.13",
|
||||
"vite-svg-loader": "^3.4.0",
|
||||
"vite-svg-loader": "^3.6.0",
|
||||
"vitest": "^0.13.1",
|
||||
"vue-tsc": "^0.31.4",
|
||||
"workbox-window": "^6.5.4"
|
||||
|
||||
2190
pnpm-lock.yaml
generated
2190
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,8 @@ a {
|
||||
}
|
||||
|
||||
.icon {
|
||||
opacity: 0.7;
|
||||
opacity: 0.6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -57,7 +58,8 @@ a {
|
||||
}
|
||||
|
||||
.description {
|
||||
opacity: 0.7;
|
||||
opacity: 0.6;
|
||||
color: #ffffff;
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,23 +134,24 @@ const menuOptions: MenuGroupOption[] = toolsByCategory.map((category) => ({
|
||||
|
||||
<search-bar />
|
||||
|
||||
<navbar-buttons v-if="!styleStore.isSmallScreen" />
|
||||
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
round
|
||||
type="primary"
|
||||
tag="a"
|
||||
href="https://github.com/sponsors/CorentinTh"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<n-icon v-if="!styleStore.isSmallScreen" :component="Heart" style="margin-right: 5px" />
|
||||
Sponsor
|
||||
Buy me a coffee
|
||||
<n-icon v-if="!styleStore.isSmallScreen" :component="Heart" style="margin-left: 5px" />
|
||||
</n-button>
|
||||
</template>
|
||||
❤ Support IT Tools development !
|
||||
</n-tooltip>
|
||||
|
||||
<navbar-buttons v-if="!styleStore.isSmallScreen" />
|
||||
</div>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
NTooltip,
|
||||
NUpload,
|
||||
NUploadDragger,
|
||||
NPopover,
|
||||
} from 'naive-ui';
|
||||
|
||||
const components = [
|
||||
@@ -109,6 +110,7 @@ const components = [
|
||||
NIcon,
|
||||
NSwitch,
|
||||
NCollapseTransition,
|
||||
NPopover,
|
||||
];
|
||||
|
||||
export const naive = create({ components });
|
||||
|
||||
@@ -30,4 +30,9 @@ export const darkThemeOverrides: GlobalThemeOverrides = {
|
||||
color: '#1e1e1e',
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
|
||||
Table: {
|
||||
tdColor: '#1e1e1e',
|
||||
thColor: '#353535',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -132,5 +132,10 @@ const formats: Format[] = [
|
||||
fromDate: (date) => date.toUTCString(),
|
||||
toDate,
|
||||
},
|
||||
{
|
||||
name: 'Mongo ObjectID',
|
||||
fromDate: (date) => Math.floor(date.getTime() / 1000).toString(16) + '0000000000000000',
|
||||
toDate: (objectId) => new Date(parseInt(objectId.substring(0, 8), 16) * 1000),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LockOpen } from '@vicons/tabler';
|
||||
import type { ToolCategory } from './tool';
|
||||
|
||||
import { tool as jwtParser } from './jwt-parser';
|
||||
import { tool as mimeTypes } from './mime-types';
|
||||
import { tool as otpCodeGeneratorAndValidator } from './otp-code-generator-and-validator';
|
||||
import { tool as base64FileConverter } from './base64-file-converter';
|
||||
import { tool as base64StringConverter } from './base64-string-converter';
|
||||
import { tool as basicAuthGenerator } from './basic-auth-generator';
|
||||
@@ -56,7 +59,17 @@ export const toolsByCategory: ToolCategory[] = [
|
||||
{
|
||||
name: 'Web',
|
||||
icon: LockOpen,
|
||||
components: [urlEncoder, htmlEntities, urlParser, deviceInformation, basicAuthGenerator, metaTagGenerator],
|
||||
components: [
|
||||
urlEncoder,
|
||||
htmlEntities,
|
||||
urlParser,
|
||||
deviceInformation,
|
||||
basicAuthGenerator,
|
||||
metaTagGenerator,
|
||||
otpCodeGeneratorAndValidator,
|
||||
mimeTypes,
|
||||
jwtParser,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Images',
|
||||
|
||||
29
src/tools/jwt-parser/claim.vue
Normal file
29
src/tools/jwt-parser/claim.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<n-space>
|
||||
<em>{{ claim }}</em>
|
||||
<span v-if="label.label !== claim">
|
||||
<n-popover placement="right" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-icon :component="InfoCircle" trigger />
|
||||
</template>
|
||||
{{ label.label }}
|
||||
<template v-if="label.ref !== ''" #footer> {{ label.ref }} </template>
|
||||
</n-popover>
|
||||
</span>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { InfoCircle } from '@vicons/tabler';
|
||||
import { get_claim_label } from './jwt-parser.service';
|
||||
|
||||
const props = defineProps({
|
||||
claim: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const label = computed(() => get_claim_label(props.claim ? props.claim : ''));
|
||||
</script>
|
||||
11
src/tools/jwt-parser/index.ts
Normal file
11
src/tools/jwt-parser/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ArrowsShuffle } from '@vicons/tabler';
|
||||
import { defineTool } from '../tool';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'JWT parser',
|
||||
path: '/jwt-parser',
|
||||
description: '',
|
||||
keywords: ['jwt', 'parser'],
|
||||
component: () => import('./jwt-parser.vue'),
|
||||
icon: ArrowsShuffle,
|
||||
});
|
||||
429
src/tools/jwt-parser/jwt-parser.service.ts
Normal file
429
src/tools/jwt-parser/jwt-parser.service.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import jwt_decode, { InvalidTokenError } from 'jwt-decode';
|
||||
|
||||
interface JWT {
|
||||
header: Map<string, unknown>;
|
||||
payload: Map<string, unknown>;
|
||||
}
|
||||
|
||||
export function safe_jwt_decode(raw_jwt: string): JWT {
|
||||
try {
|
||||
const header = jwt_decode(raw_jwt, { header: true }) as Map<string, unknown>;
|
||||
const payload = jwt_decode(raw_jwt) as Map<string, unknown>;
|
||||
return { header: header, payload: payload };
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidTokenError) {
|
||||
return { header: new Map<string, unknown>(), payload: new Map<string, unknown>() };
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function get_claim_label(claim: string): { label: string; ref: string } {
|
||||
const infos = STANDARD_CLAIMS.find((info) => info.name === claim);
|
||||
if (infos) {
|
||||
return { label: infos.long_name, ref: infos.ref };
|
||||
}
|
||||
switch (claim) {
|
||||
case 'typ':
|
||||
return { label: 'Type', ref: '' };
|
||||
case 'alg':
|
||||
return { label: 'Algorithm', ref: '' };
|
||||
}
|
||||
return { label: claim, ref: '' };
|
||||
}
|
||||
|
||||
export function parse_claim_value(claim: string, value: unknown): { value: unknown; extension?: unknown } {
|
||||
switch (claim) {
|
||||
case 'exp':
|
||||
case 'nbf':
|
||||
case 'iat': {
|
||||
// Convert to milliseconds, JWT specs says it should be in seconds, JS
|
||||
// works with milliseconds
|
||||
value = typeof value === 'string' ? parseInt(value) : value;
|
||||
const date = new Date((value as number) * 1000);
|
||||
return { value: `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`, extension: value };
|
||||
}
|
||||
case 'alg':
|
||||
return { value: AlgorithmKeyDescriptionMapping[value as string], extension: value };
|
||||
default:
|
||||
if (typeof value === 'boolean') {
|
||||
// Perhaps there's a better way to do this?
|
||||
return { value: value ? 'true' : 'false' };
|
||||
}
|
||||
return { value: value };
|
||||
}
|
||||
}
|
||||
|
||||
// From https://datatracker.ietf.org/doc/html/rfc7518#section-3.1
|
||||
const AlgorithmKeyDescriptionMapping: { [k: string]: string } = {
|
||||
HS256: 'HMAC using SHA-256',
|
||||
HS384: 'HMAC using SHA-384',
|
||||
HS512: 'HMAC using SHA-512',
|
||||
RS256: 'RSASSA-PKCS1-v1_5 using SHA-256',
|
||||
RS384: 'RSASSA-PKCS1-v1_5 using SHA-384',
|
||||
RS512: 'RSASSA-PKCS1-v1_5 using SHA-512',
|
||||
ES256: 'ECDSA using P-256 and SHA-256',
|
||||
ES384: 'ECDSA using P-384 and SHA-384',
|
||||
ES512: 'ECDSA using P-521 and SHA-512',
|
||||
PS256: 'RSASSA-PSS using SHA-256 and MGF1 with SHA-256',
|
||||
PS384: 'RSASSA-PSS using SHA-384 and MGF1 with SHA-384',
|
||||
PS512: 'RSASSA-PSS using SHA-512 and MGF1 with SHA-512',
|
||||
none: 'No digital signature or MAC performed',
|
||||
};
|
||||
|
||||
// List extracted from IANA: https://www.iana.org/assignments/jwt/jwt.xhtml
|
||||
const STANDARD_CLAIMS = [
|
||||
{
|
||||
name: 'iss',
|
||||
long_name: 'Issuer',
|
||||
ref: '[RFC7519 - Section 4.1.1]',
|
||||
},
|
||||
{
|
||||
name: 'sub',
|
||||
long_name: 'Subject',
|
||||
ref: '[RFC7519 - Section 4.1.2]',
|
||||
},
|
||||
{
|
||||
name: 'aud',
|
||||
long_name: 'Audience',
|
||||
ref: '[RFC7519 - Section 4.1.3]',
|
||||
},
|
||||
{
|
||||
name: 'exp',
|
||||
long_name: 'Expiration Time',
|
||||
ref: '[RFC7519 - Section 4.1.4]',
|
||||
},
|
||||
{
|
||||
name: 'nbf',
|
||||
long_name: 'Not Before',
|
||||
ref: '[RFC7519 - Section 4.1.5]',
|
||||
},
|
||||
{
|
||||
name: 'iat',
|
||||
long_name: 'Issued At',
|
||||
ref: '[RFC7519 - Section 4.1.6]',
|
||||
},
|
||||
{
|
||||
name: 'jti',
|
||||
long_name: 'JWT ID',
|
||||
ref: '[RFC7519 - Section 4.1.7]',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
long_name: 'Full name',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'given_name',
|
||||
long_name: 'Given name(s) or first name(s)',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'family_name',
|
||||
long_name: 'Surname(s) or last name(s)',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'middle_name',
|
||||
long_name: 'Middle name(s)',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'nickname',
|
||||
long_name: 'Casual name',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'preferred_username',
|
||||
long_name: 'Shorthand name by which the End-User wishes to be referred to',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'profile',
|
||||
long_name: 'Profile page URL',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'picture',
|
||||
long_name: 'Profile picture URL',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'website',
|
||||
long_name: 'Web page or blog URL',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
long_name: 'Preferred e-mail address',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'email_verified',
|
||||
long_name: 'True if the e-mail address has been verified; otherwise false',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'gender',
|
||||
long_name: 'Gender',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'birthdate',
|
||||
long_name: 'Birthday',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'zoneinfo',
|
||||
long_name: 'Time zone',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'locale',
|
||||
long_name: 'Locale',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'phone_number',
|
||||
long_name: 'Preferred telephone number',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'phone_number_verified',
|
||||
long_name: 'True if the phone number has been verified; otherwise false',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
long_name: 'Preferred postal address',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
long_name: 'Time the information was last updated',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 5.1]',
|
||||
},
|
||||
{
|
||||
name: 'azp',
|
||||
long_name: 'Authorized party - the party to which the ID Token was issued',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 2]',
|
||||
},
|
||||
{
|
||||
name: 'nonce',
|
||||
long_name: 'Value used to associate a Client session with an ID Token',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 2]',
|
||||
},
|
||||
{
|
||||
name: 'auth_time',
|
||||
long_name: 'Time when the authentication occurred',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 2]',
|
||||
},
|
||||
{
|
||||
name: 'at_hash',
|
||||
long_name: 'Access Token hash value',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 2]',
|
||||
},
|
||||
{
|
||||
name: 'c_hash',
|
||||
long_name: 'Code hash value',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 3.3.2.11]',
|
||||
},
|
||||
{
|
||||
name: 'acr',
|
||||
long_name: 'Authentication Context Class Reference',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 2]',
|
||||
},
|
||||
{
|
||||
name: 'amr',
|
||||
long_name: 'Authentication Methods References',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 2]',
|
||||
},
|
||||
{
|
||||
name: 'sub_jwk',
|
||||
long_name: 'Public key used to check the signature of an ID Token',
|
||||
ref: '[OpenID Connect Core 1.0 - Section 7.4]',
|
||||
},
|
||||
{
|
||||
name: 'cnf',
|
||||
long_name: 'Confirmation',
|
||||
ref: '[RFC7800 - Section 3.1]',
|
||||
},
|
||||
{
|
||||
name: 'sip_from_tag',
|
||||
long_name: 'SIP From tag header field parameter value',
|
||||
ref: '[RFC8055][RFC3261]',
|
||||
},
|
||||
{
|
||||
name: 'sip_date',
|
||||
long_name: 'SIP Date header field value',
|
||||
ref: '[RFC8055][RFC3261]',
|
||||
},
|
||||
{
|
||||
name: 'sip_callid',
|
||||
long_name: 'SIP Call-Id header field value',
|
||||
ref: '[RFC8055][RFC3261]',
|
||||
},
|
||||
{
|
||||
name: 'sip_cseq_num',
|
||||
long_name: 'SIP CSeq numeric header field parameter value',
|
||||
ref: '[RFC8055][RFC3261]',
|
||||
},
|
||||
{
|
||||
name: 'sip_via_branch',
|
||||
long_name: 'SIP Via branch header field parameter value',
|
||||
ref: '[RFC8055][RFC3261]',
|
||||
},
|
||||
{
|
||||
name: 'orig',
|
||||
long_name: 'Originating Identity String',
|
||||
ref: '[RFC8225 - Section 5.2.1]',
|
||||
},
|
||||
{
|
||||
name: 'dest',
|
||||
long_name: 'Destination Identity String',
|
||||
ref: '[RFC8225 - Section 5.2.1]',
|
||||
},
|
||||
{
|
||||
name: 'mky',
|
||||
long_name: 'Media Key Fingerprint String',
|
||||
ref: '[RFC8225 - Section 5.2.2]',
|
||||
},
|
||||
{
|
||||
name: 'events',
|
||||
long_name: 'Security Events',
|
||||
ref: '[RFC8417 - Section 2.2]',
|
||||
},
|
||||
{
|
||||
name: 'toe',
|
||||
long_name: 'Time of Event',
|
||||
ref: '[RFC8417 - Section 2.2]',
|
||||
},
|
||||
{
|
||||
name: 'txn',
|
||||
long_name: 'Transaction Identifier',
|
||||
ref: '[RFC8417 - Section 2.2]',
|
||||
},
|
||||
{
|
||||
name: 'rph',
|
||||
long_name: 'Resource Priority Header Authorization',
|
||||
ref: '[RFC8443 - Section 3]',
|
||||
},
|
||||
{
|
||||
name: 'sid',
|
||||
long_name: 'Session ID',
|
||||
ref: '[OpenID Connect Front-Channel Logout 1.0 - Section 3]',
|
||||
},
|
||||
{
|
||||
name: 'vot',
|
||||
long_name: 'Vector of Trust value',
|
||||
ref: '[RFC8485]',
|
||||
},
|
||||
{
|
||||
name: 'vtm',
|
||||
long_name: 'Vector of Trust trustmark URL',
|
||||
ref: '[RFC8485]',
|
||||
},
|
||||
{
|
||||
name: 'attest',
|
||||
long_name: 'Attestation level as defined in SHAKEN framework',
|
||||
ref: '[RFC8588]',
|
||||
},
|
||||
{
|
||||
name: 'origid',
|
||||
long_name: 'Originating Identifier as defined in SHAKEN framework',
|
||||
ref: '[RFC8588]',
|
||||
},
|
||||
{
|
||||
name: 'act',
|
||||
long_name: 'Actor',
|
||||
ref: '[RFC8693 - Section 4.1]',
|
||||
},
|
||||
{
|
||||
name: 'scope',
|
||||
long_name: 'Scope Values',
|
||||
ref: '[RFC8693 - Section 4.2]',
|
||||
},
|
||||
{
|
||||
name: 'client_id',
|
||||
long_name: 'Client Identifier',
|
||||
ref: '[RFC8693 - Section 4.3]',
|
||||
},
|
||||
{
|
||||
name: 'may_act',
|
||||
long_name: 'Authorized Actor - the party that is authorized to become the actor',
|
||||
ref: '[RFC8693 - Section 4.4]',
|
||||
},
|
||||
{
|
||||
name: 'jcard',
|
||||
long_name: 'jCard data',
|
||||
ref: '[RFC8688][RFC7095]',
|
||||
},
|
||||
{
|
||||
name: 'at_use_nbr',
|
||||
long_name: 'Number of API requests for which the access token can be used',
|
||||
ref: '[ETSI GS NFV-SEC 022 V2.7.1]',
|
||||
},
|
||||
{
|
||||
name: 'div',
|
||||
long_name: 'Diverted Target of a Call',
|
||||
ref: '[RFC8946]',
|
||||
},
|
||||
{
|
||||
name: 'opt',
|
||||
long_name: 'Original PASSporT (in Full Form)',
|
||||
ref: '[RFC8946]',
|
||||
},
|
||||
{
|
||||
name: 'vc',
|
||||
long_name: 'Verifiable Credential as specified in the W3C Recommendation',
|
||||
ref: '[W3C Recommendation Verifiable Credentials Data Model 1.0 - Expressing verifiable information on the Web (19 November 2019) - Section 6.3.1]',
|
||||
},
|
||||
{
|
||||
name: 'vp',
|
||||
long_name: 'Verifiable Presentation as specified in the W3C Recommendation',
|
||||
ref: '[W3C Recommendation Verifiable Credentials Data Model 1.0 - Expressing verifiable information on the Web (19 November 2019) - Section 6.3.1]',
|
||||
},
|
||||
{
|
||||
name: 'sph',
|
||||
long_name: 'SIP Priority header field',
|
||||
ref: '[RFC9027]',
|
||||
},
|
||||
{
|
||||
name: 'ace_profile',
|
||||
long_name: 'The ACE profile a token is supposed to be used with.',
|
||||
ref: '[RFC-ietf-ace-oauth-authz-46 - Section 5.10]',
|
||||
},
|
||||
{
|
||||
name: 'cnonce',
|
||||
long_name:
|
||||
'client-nonce. A nonce previously provided to the AS by the RS via the client. Used to verify token freshness when the RS cannot synchronize its clock with the AS.',
|
||||
ref: '[RFC-ietf-ace-oauth-authz-46 - Section 5.10]',
|
||||
},
|
||||
{
|
||||
name: 'exi',
|
||||
long_name:
|
||||
'Expires in. Lifetime of the token in seconds from the time the RS first sees it. Used to implement a weaker from of token expiration for devices that cannot synchronize their internal clocks.',
|
||||
ref: '[RFC-ietf-ace-oauth-authz-46 - Section 5.10.3]',
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
long_name: 'Roles',
|
||||
ref: '[RFC7643 - Section 4.1.2][RFC9068 - Section 2.2.3.1]',
|
||||
},
|
||||
{
|
||||
name: 'groups',
|
||||
long_name: 'Groups',
|
||||
ref: '[RFC7643 - Section 4.1.2][RFC9068 - Section 2.2.3.1]',
|
||||
},
|
||||
{
|
||||
name: 'entitlements',
|
||||
long_name: 'Entitlements',
|
||||
ref: '[RFC7643 - Section 4.1.2][RFC9068 - Section 2.2.3.1]',
|
||||
},
|
||||
{
|
||||
name: 'token_introspection',
|
||||
long_name: 'Token introspection response',
|
||||
ref: '[RFC-ietf-oauth-jwt-introspection-response-12 - Section 5]',
|
||||
},
|
||||
];
|
||||
69
src/tools/jwt-parser/jwt-parser.vue
Normal file
69
src/tools/jwt-parser/jwt-parser.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<n-card>
|
||||
<n-form-item label="JWT to decode" :feedback="validation.message" :validation-status="validation.status">
|
||||
<n-input v-model:value="raw_jwt" type="textarea" placeholder="Put your token here..." rows="5" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Display parsed value?" label-placement="left" :show-feedback="false">
|
||||
<n-switch v-model:value="showParsedValues" />
|
||||
</n-form-item>
|
||||
|
||||
<n-table>
|
||||
<tbody>
|
||||
<td colspan="2" class="table-header"><strong>Header</strong></td>
|
||||
<tr v-for="[key, value] in Object.entries(decodedJWT.header)" :key="key">
|
||||
<td class="claims"><claim-vue :claim="key" /></td>
|
||||
<td>
|
||||
<span v-if="!showParsedValues">{{ value }}</span>
|
||||
<value-vue v-else :claim="key" :value="value" />
|
||||
</td>
|
||||
</tr>
|
||||
<td colspan="2" class="table-header"><strong>Payload</strong></td>
|
||||
<tr v-for="[key, value] in Object.entries(decodedJWT.payload)" :key="key">
|
||||
<td class="claims"><claim-vue :claim="key" /></td>
|
||||
<td>
|
||||
<span v-if="!showParsedValues">{{ value }}</span>
|
||||
<value-vue v-else :claim="key" :value="value" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import jwt_decode from 'jwt-decode';
|
||||
import { useValidation } from '@/composable/validation';
|
||||
import { isNotThrowing } from '@/utils/boolean';
|
||||
import { safe_jwt_decode } from './jwt-parser.service';
|
||||
import claimVue from './claim.vue';
|
||||
import valueVue from './value.vue';
|
||||
|
||||
const raw_jwt = ref(
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
|
||||
);
|
||||
const showParsedValues = ref(true);
|
||||
|
||||
const decodedJWT = computed(() => {
|
||||
return safe_jwt_decode(raw_jwt.value);
|
||||
});
|
||||
const validation = useValidation({
|
||||
source: raw_jwt,
|
||||
rules: [
|
||||
{
|
||||
validator: (value) => value.length > 0 && isNotThrowing(() => jwt_decode(value, { header: true })),
|
||||
message: 'Invalid JWT',
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.table-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.claims {
|
||||
width: 20%;
|
||||
}
|
||||
</style>
|
||||
24
src/tools/jwt-parser/value.vue
Normal file
24
src/tools/jwt-parser/value.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<n-space>
|
||||
{{ value.value }}
|
||||
<em v-if="value.extension">({{ value.extension }})</em>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { parse_claim_value } from './jwt-parser.service';
|
||||
|
||||
const props = defineProps({
|
||||
claim: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const value = computed(() => parse_claim_value(props.claim, props.value));
|
||||
</script>
|
||||
11
src/tools/mime-types/index.ts
Normal file
11
src/tools/mime-types/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { World } from '@vicons/tabler';
|
||||
import { defineTool } from '../tool';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'Mime types',
|
||||
path: '/mime-types',
|
||||
description: 'Convert mime types to extensions and vice-versa.',
|
||||
keywords: ['mime', 'types', 'extension', 'content', 'type'],
|
||||
component: () => import('./mime-types.vue'),
|
||||
icon: World,
|
||||
});
|
||||
99
src/tools/mime-types/mime-types.vue
Normal file
99
src/tools/mime-types/mime-types.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<n-card>
|
||||
<n-h2 style="margin-bottom: 0">Mime type to extension</n-h2>
|
||||
<div style="opacity: 0.8">Now witch file extensions are associated to a mime-type</div>
|
||||
<n-form-item>
|
||||
<n-select
|
||||
v-model:value="selectedMimeType"
|
||||
filterable
|
||||
:options="mimeToExtensionsOptions"
|
||||
size="large"
|
||||
placeholder="Select your mimetype here... (ex: application/pdf)"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<div v-if="extensionsFound.length > 0">
|
||||
Extensions of files with the <n-tag round :bordered="false">{{ selectedMimeType }}</n-tag> mime-type:
|
||||
<div style="margin-top: 10px">
|
||||
<n-tag
|
||||
v-for="extension of extensionsFound"
|
||||
:key="extension"
|
||||
round
|
||||
:bordered="false"
|
||||
type="primary"
|
||||
style="margin-right: 10px"
|
||||
>
|
||||
.{{ extension }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card>
|
||||
<n-h2 style="margin-bottom: 0">File extension to mime type</n-h2>
|
||||
<div style="opacity: 0.8">Now witch mime type is associated to a file extension</div>
|
||||
<n-form-item>
|
||||
<n-select
|
||||
v-model:value="selectedExtension"
|
||||
filterable
|
||||
:options="extensionToMimeTypeOptions"
|
||||
size="large"
|
||||
placeholder="Select your mimetype here... (ex: application/pdf)"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<div v-if="selectedExtension">
|
||||
Mime type associated to the extension <n-tag round :bordered="false">{{ selectedExtension }}</n-tag> file
|
||||
extension:
|
||||
<div style="margin-top: 10px">
|
||||
<n-tag round :bordered="false" type="primary" style="margin-right: 10px">
|
||||
{{ mimeTypeFound }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<div>
|
||||
<n-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mime types</th>
|
||||
<th>Extensions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="{ mimeType, extensions } of mimeInfos" :key="mimeType">
|
||||
<td>{{ mimeType }}</td>
|
||||
<td>
|
||||
<n-tag v-for="extension of extensions" :key="extension" round :bordered="false" style="margin-right: 10px">
|
||||
.{{ extension }}
|
||||
</n-tag>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { types as extensionToMimeType, extensions as mimeTypeToExtension } from 'mime-types';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const mimeInfos = Object.entries(mimeTypeToExtension).map(([mimeType, extensions]) => ({ mimeType, extensions }));
|
||||
|
||||
const mimeToExtensionsOptions = Object.keys(mimeTypeToExtension).map((label) => ({ label, value: label }));
|
||||
const selectedMimeType = ref(undefined);
|
||||
|
||||
const extensionsFound = computed(() => (selectedMimeType.value ? mimeTypeToExtension[selectedMimeType.value] : []));
|
||||
|
||||
const extensionToMimeTypeOptions = Object.keys(extensionToMimeType).map((label) => {
|
||||
const extension = `.${label}`;
|
||||
|
||||
return { label: extension, value: label };
|
||||
});
|
||||
const selectedExtension = ref(undefined);
|
||||
|
||||
const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeType[selectedExtension.value] : []));
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped></style>
|
||||
27
src/tools/otp-code-generator-and-validator/index.ts
Normal file
27
src/tools/otp-code-generator-and-validator/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { DeviceMobile } from '@vicons/tabler';
|
||||
import { defineTool } from '../tool';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'OTP code generator',
|
||||
path: '/otp-generator',
|
||||
description: 'Generate and validate time-based OTP (one time password) for multi-factor authentication.',
|
||||
keywords: [
|
||||
'otp',
|
||||
'code',
|
||||
'generator',
|
||||
'validator',
|
||||
'one',
|
||||
'time',
|
||||
'password',
|
||||
'authentication',
|
||||
'MFA',
|
||||
'mobile',
|
||||
'device',
|
||||
'security',
|
||||
'TOTP',
|
||||
'Time',
|
||||
'HMAC',
|
||||
],
|
||||
component: () => import('./otp-code-generator-and-validator.vue'),
|
||||
icon: DeviceMobile,
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div style="max-width: 350px">
|
||||
<n-form-item label="Secret" v-bind="secretValidationAttrs">
|
||||
<n-input v-model:value="secret" placeholder="Paste your TOTP secret...">
|
||||
<template #suffix>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button quaternary circle @click="refreshSecret">
|
||||
<n-icon :component="Refresh" />
|
||||
</n-button>
|
||||
</template>
|
||||
Generate secret token
|
||||
</n-tooltip>
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<div>
|
||||
<token-display :tokens="tokens" style="margin-top: 2px" />
|
||||
|
||||
<n-progress :percentage="(100 * interval) / 30" :color="theme.primaryColor" :show-indicator="false" />
|
||||
<div style="text-align: center">Next in {{ String(Math.floor(30 - interval)).padStart(2, '0') }}s</div>
|
||||
</div>
|
||||
<n-space justify="center" vertical align="center" style="margin-top: 10px">
|
||||
<n-image :src="qrcode"></n-image>
|
||||
<n-button secondary tag="a" :href="keyUri" target="_blank">Open Key URI in new tab</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<div style="max-width: 350px">
|
||||
<n-form-item label="Secret in hexadecimal">
|
||||
<input-copyable :value="base32toHex(secret)" readonly placeholder="Secret in hex will be displayed here" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Epoch">
|
||||
<input-copyable
|
||||
:value="Math.floor(now / 1000).toString()"
|
||||
readonly
|
||||
placeholder="Epoch in sec will be displayed here"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="Iteration" :show-feedback="false">
|
||||
<n-input-group>
|
||||
<n-input-group-label style="width: 110px">Count:</n-input-group-label>
|
||||
<input-copyable
|
||||
:value="String(getCounterFromTime({ now, timeStep: 30 }))"
|
||||
readonly
|
||||
placeholder="Iteration count will be displayed here"
|
||||
/>
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Iteration" :show-label="false" style="margin-top: 5px">
|
||||
<n-input-group>
|
||||
<n-input-group-label style="width: 110px">Padded hex:</n-input-group-label>
|
||||
<input-copyable
|
||||
:value="getCounterFromTime({ now, timeStep: 30 }).toString(16).padStart(16, '0')"
|
||||
readonly
|
||||
placeholder="Iteration count in hex will be displayed here"
|
||||
/>
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Refresh } from '@vicons/tabler';
|
||||
import { useTimestamp, whenever } from '@vueuse/core';
|
||||
import { useThemeVars } from 'naive-ui';
|
||||
import { useStyleStore } from '@/stores/style.store';
|
||||
import InputCopyable from '@/components/InputCopyable.vue';
|
||||
import { useValidation } from '@/composable/validation';
|
||||
import { generateTOTP, buildKeyUri, generateSecret, base32toHex, getCounterFromTime } from './otp.service';
|
||||
import { useQRCode } from '../qr-code-generator/useQRCode';
|
||||
import TokenDisplay from './token-display.vue';
|
||||
|
||||
const now = useTimestamp();
|
||||
const interval = computed(() => (now.value / 1000) % 30);
|
||||
const theme = useThemeVars();
|
||||
const styleStore = useStyleStore();
|
||||
const secret = ref(generateSecret());
|
||||
const tokens = ref(buildTokens());
|
||||
const keyUri = computed(() => buildKeyUri({ secret: secret.value }));
|
||||
|
||||
const { qrcode } = useQRCode({
|
||||
text: keyUri,
|
||||
color: { background: '#00000000', foreground: computed(() => (styleStore.isDarkTheme ? '#ffffff' : '#000000')) },
|
||||
options: { width: 210 },
|
||||
});
|
||||
|
||||
const { attrs: secretValidationAttrs } = useValidation({
|
||||
source: secret,
|
||||
rules: [
|
||||
{
|
||||
message: 'Secret should be a base32 string',
|
||||
validator: (value) => value.match(/^[A-Z234567]+$/),
|
||||
},
|
||||
{
|
||||
message: 'Please set a secret',
|
||||
validator: (value) => value !== '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// watch + whenever to prevent token to be refresh every raf
|
||||
watch([secret], refreshToken);
|
||||
whenever(() => Math.floor(interval.value) === 0, refreshToken);
|
||||
|
||||
function refreshSecret() {
|
||||
secret.value = generateSecret();
|
||||
}
|
||||
|
||||
function refreshToken() {
|
||||
tokens.value = buildTokens();
|
||||
}
|
||||
|
||||
function buildTokens() {
|
||||
return {
|
||||
previous: generateTOTP({ key: secret.value, now: now.value - 30000 }),
|
||||
current: generateTOTP({ key: secret.value, now: now.value }),
|
||||
next: generateTOTP({ key: secret.value, now: now.value + 30000 }),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.n-progress {
|
||||
margin-top: 10px;
|
||||
::v-deep(.n-progress-graph-line-fill) {
|
||||
transition-duration: 0.05s !important;
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
text-align: center;
|
||||
&.token-current {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
124
src/tools/otp-code-generator-and-validator/otp.service.test.ts
Normal file
124
src/tools/otp-code-generator-and-validator/otp.service.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
generateHOTP,
|
||||
hexToBytes,
|
||||
verifyHOTP,
|
||||
generateTOTP,
|
||||
verifyTOTP,
|
||||
buildKeyUri,
|
||||
base32toHex,
|
||||
} from './otp.service';
|
||||
|
||||
describe('otp functions', () => {
|
||||
describe('hexToBytes', () => {
|
||||
it('convert an hexstring to a byte array', () => {
|
||||
expect(hexToBytes('1')).to.eql([1]);
|
||||
expect(hexToBytes('ffffff')).to.eql([255, 255, 255]);
|
||||
expect(hexToBytes('000000000')).to.eql([0, 0, 0, 0, 0]);
|
||||
expect(hexToBytes('a3218bcef89')).to.eql([163, 33, 139, 206, 248, 9]);
|
||||
expect(hexToBytes('063679ca')).toEqual([6, 54, 121, 202]);
|
||||
expect(hexToBytes('0102030405060708090a0b0c0d0e0f')).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
|
||||
});
|
||||
});
|
||||
describe('base32tohex', () => {
|
||||
it('convert a base32 to hex string', () => {
|
||||
expect(base32toHex('ABCDEF')).to.eql('00443205');
|
||||
expect(base32toHex('7777')).to.eql('ffff0f');
|
||||
expect(base32toHex('JBSWY3DPEHPK3PXP')).to.eql('48656c6c6f21deadbeef');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateHOTP', () => {
|
||||
it('generates HOTP codes for a given counter', () => {
|
||||
const key = 'JBSWY3DPEHPK3PXP';
|
||||
const hotpCodes = ['282760', '996554', '602287', '143627', '960129'];
|
||||
|
||||
for (const [counter, code] of hotpCodes.entries()) {
|
||||
expect(generateHOTP({ key, counter })).to.eql(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyHOTP', () => {
|
||||
it('validate hotp for a given secret', () => {
|
||||
const key = 'JBSWY3DPEHPK3PXP';
|
||||
const hotpCodes = ['282760', '996554', '602287', '143627', '960129'];
|
||||
|
||||
for (const [counter, token] of hotpCodes.entries()) {
|
||||
expect(verifyHOTP({ token, key, counter, window: 0 })).to.eql(true);
|
||||
}
|
||||
|
||||
expect(verifyHOTP({ token: 'INVALID', key })).to.eql(false);
|
||||
});
|
||||
|
||||
it('does not validate hotp out of sync', () => {
|
||||
const key = 'JBSWY3DPEHPK3PXP';
|
||||
const token = '282760';
|
||||
|
||||
expect(verifyHOTP({ token, key, counter: 5, window: 2 })).to.eql(false);
|
||||
expect(verifyHOTP({ token, key, counter: 5, window: 5 })).to.eql(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateTOTP', () => {
|
||||
it('generates TOTP codes', () => {
|
||||
const key = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
const codes = [
|
||||
{ token: '282760', now: 0 },
|
||||
{ token: '341128', now: 1465324707000 },
|
||||
{ token: '089029', now: 1365324707000 },
|
||||
];
|
||||
|
||||
for (const { token, now } of codes) {
|
||||
expect(generateTOTP({ key, now })).to.eql(token);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyTOTP', () => {
|
||||
it('verify TOTP in sync codes against a key', () => {
|
||||
const key = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
const codes = [
|
||||
{ token: '282760', now: 0 },
|
||||
{ token: '341128', now: 1465324707000 },
|
||||
{ token: '089029', now: 1365324707000 },
|
||||
];
|
||||
|
||||
for (const { token, now } of codes) {
|
||||
expect(verifyTOTP({ key, token, now })).to.eql(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not validate totp out of sync', () => {
|
||||
const key = 'JBSWY3DPEHPK3PXP';
|
||||
const token = '635183';
|
||||
const now = 1661266455000;
|
||||
|
||||
expect(verifyTOTP({ key, token, now, window: 2 })).to.eql(true);
|
||||
expect(verifyTOTP({ key, token, now, window: 1 })).to.eql(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildKeyUri', () => {
|
||||
it('build a key uri string', () => {
|
||||
expect(buildKeyUri({ secret: 'JBSWY3DPEHPK3PXP' })).to.eql(
|
||||
'otpauth://totp/IT-Tools:demo-user?issuer=IT-Tools&secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30',
|
||||
);
|
||||
|
||||
expect(
|
||||
buildKeyUri({
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
app: 'app-name',
|
||||
account: 'account',
|
||||
algorithm: 'algo',
|
||||
digits: 7,
|
||||
period: 10,
|
||||
}),
|
||||
).to.eql(
|
||||
'otpauth://totp/app-name:account?issuer=app-name&secret=JBSWY3DPEHPK3PXP&algorithm=algo&digits=7&period=10',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
src/tools/otp-code-generator-and-validator/otp.service.ts
Normal file
139
src/tools/otp-code-generator-and-validator/otp.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { enc, HmacSHA1 } from 'crypto-js';
|
||||
import _ from 'lodash';
|
||||
import { createToken } from '../token-generator/token-generator.service';
|
||||
|
||||
export {
|
||||
generateHOTP,
|
||||
hexToBytes,
|
||||
verifyHOTP,
|
||||
generateTOTP,
|
||||
verifyTOTP,
|
||||
buildKeyUri,
|
||||
generateSecret,
|
||||
base32toHex,
|
||||
getCounterFromTime,
|
||||
};
|
||||
|
||||
function hexToBytes(hex: string) {
|
||||
return (hex.match(/.{1,2}/g) ?? []).map((char) => parseInt(char, 16));
|
||||
}
|
||||
|
||||
function computeHMACSha1(message: string, key: string) {
|
||||
return HmacSHA1(enc.Hex.parse(message), enc.Hex.parse(base32toHex(key))).toString(enc.Hex);
|
||||
}
|
||||
|
||||
function base32toHex(base32: string) {
|
||||
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
const bits = base32
|
||||
.replace(/=+$/, '')
|
||||
.split('')
|
||||
.map((value) => base32Chars.indexOf(value).toString(2).padStart(5, '0'))
|
||||
.join('');
|
||||
|
||||
const hex = (bits.match(/.{1,8}/g) ?? []).map((chunk) => parseInt(chunk, 2).toString(16).padStart(2, '0')).join('');
|
||||
|
||||
return hex;
|
||||
}
|
||||
|
||||
function generateHOTP({ key, counter = 0 }: { key: string; counter?: number }) {
|
||||
// Compute HMACdigest
|
||||
const digest = computeHMACSha1(counter.toString(16).padStart(16, '0'), key);
|
||||
|
||||
// Get byte array
|
||||
const bytes = hexToBytes(digest);
|
||||
|
||||
// Truncate
|
||||
const offset = bytes[19] & 0xf;
|
||||
const v =
|
||||
((bytes[offset] & 0x7f) << 24) |
|
||||
((bytes[offset + 1] & 0xff) << 16) |
|
||||
((bytes[offset + 2] & 0xff) << 8) |
|
||||
(bytes[offset + 3] & 0xff);
|
||||
|
||||
const code = String(v % 1000000).padStart(6, '0');
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
function verifyHOTP({
|
||||
token,
|
||||
key,
|
||||
window = 0,
|
||||
counter = 0,
|
||||
}: {
|
||||
token: string;
|
||||
key: string;
|
||||
window?: number;
|
||||
counter?: number;
|
||||
}) {
|
||||
for (let i = counter - window; i <= counter + window; ++i) {
|
||||
if (generateHOTP({ key, counter: i }) === token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getCounterFromTime({ now, timeStep }: { now: number; timeStep: number }) {
|
||||
return Math.floor(now / 1000 / timeStep);
|
||||
}
|
||||
|
||||
function generateTOTP({ key, now = Date.now(), timeStep = 30 }: { key: string; now?: number; timeStep?: number }) {
|
||||
const counter = getCounterFromTime({ now, timeStep });
|
||||
|
||||
return generateHOTP({ key, counter });
|
||||
}
|
||||
|
||||
function verifyTOTP({
|
||||
key,
|
||||
token,
|
||||
window = 0,
|
||||
now = Date.now(),
|
||||
timeStep = 30,
|
||||
}: {
|
||||
token: string;
|
||||
key: string;
|
||||
window?: number;
|
||||
now?: number;
|
||||
timeStep?: number;
|
||||
}) {
|
||||
const counter = getCounterFromTime({ now, timeStep });
|
||||
|
||||
return verifyHOTP({ token, key, window, counter });
|
||||
}
|
||||
|
||||
function buildKeyUri({
|
||||
secret,
|
||||
app = 'IT-Tools',
|
||||
account = 'demo-user',
|
||||
algorithm = 'SHA1',
|
||||
digits = 6,
|
||||
period = 30,
|
||||
}: {
|
||||
secret: string;
|
||||
app?: string;
|
||||
account?: string;
|
||||
algorithm?: string;
|
||||
digits?: number;
|
||||
period?: number;
|
||||
}) {
|
||||
const params = {
|
||||
issuer: app,
|
||||
secret,
|
||||
algorithm,
|
||||
digits,
|
||||
period,
|
||||
};
|
||||
|
||||
const paramsString = _(params)
|
||||
.map((value, key) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
|
||||
return `otpauth://totp/${encodeURIComponent(app)}:${encodeURIComponent(account)}?${paramsString}`;
|
||||
}
|
||||
|
||||
function generateSecret() {
|
||||
return createToken({ length: 16, alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' });
|
||||
}
|
||||
66
src/tools/otp-code-generator-and-validator/token-display.vue
Normal file
66
src/tools/otp-code-generator-and-validator/token-display.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-space class="labels" item-style="flex: 1 1 0" style="width: 100%" align="center">
|
||||
<div style="text-align: left">Previous</div>
|
||||
<div style="text-align: center">Current OTP</div>
|
||||
<div style="text-align: right">Next</div>
|
||||
</n-space>
|
||||
<n-input-group>
|
||||
<n-tooltip trigger="hover" placement="bottom">
|
||||
<template #trigger>
|
||||
<n-button secondary @click.prevent="copyPrevious(tokens.previous)">{{ tokens.previous }}</n-button>
|
||||
</template>
|
||||
<div>{{ previousCopied ? 'Copied !' : 'Copy previous OTP' }}</div>
|
||||
</n-tooltip>
|
||||
<n-tooltip trigger="hover" placement="bottom">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="primary" class="current-otp" @click.prevent="copyCurrent(tokens.current)">
|
||||
{{ tokens.current }}
|
||||
</n-button>
|
||||
</template>
|
||||
<div>{{ currentCopied ? 'Copied !' : 'Copy current OTP' }}</div>
|
||||
</n-tooltip>
|
||||
<n-tooltip trigger="hover" placement="bottom">
|
||||
<template #trigger>
|
||||
<n-button secondary @click.prevent="copyNext(tokens.next)">{{ tokens.next }}</n-button>
|
||||
</template>
|
||||
<div>{{ nextCopied ? 'Copied !' : 'Copy next OTP' }}</div>
|
||||
</n-tooltip>
|
||||
</n-input-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { toRefs } from 'vue';
|
||||
|
||||
const { copy: copyPrevious, copied: previousCopied } = useClipboard();
|
||||
const { copy: copyCurrent, copied: currentCopied } = useClipboard();
|
||||
const { copy: copyNext, copied: nextCopied } = useClipboard();
|
||||
|
||||
const props = defineProps<{ tokens: { previous: string; current: string; next: string } }>();
|
||||
const { tokens } = toRefs(props);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.current-otp {
|
||||
font-size: 22px;
|
||||
flex: 1 0 35% !important;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.labels {
|
||||
div {
|
||||
text-align: center;
|
||||
padding: 0 2px 6px 2px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
.n-input-group > * {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { get, type MaybeRef } from '@vueuse/core';
|
||||
import QRCode, { type QRCodeErrorCorrectionLevel, type QRCodeToDataURLOptions } from 'qrcode';
|
||||
import { ref, watch, type Ref } from 'vue';
|
||||
import { ref, watch, isRef } from 'vue';
|
||||
|
||||
export function useQRCode({
|
||||
text,
|
||||
@@ -7,24 +8,24 @@ export function useQRCode({
|
||||
errorCorrectionLevel,
|
||||
options,
|
||||
}: {
|
||||
text: Ref<string>;
|
||||
color: { foreground: Ref<string>; background: Ref<string> };
|
||||
errorCorrectionLevel: Ref<QRCodeErrorCorrectionLevel>;
|
||||
text: MaybeRef<string>;
|
||||
color: { foreground: MaybeRef<string>; background: MaybeRef<string> };
|
||||
errorCorrectionLevel?: MaybeRef<QRCodeErrorCorrectionLevel>;
|
||||
options?: QRCodeToDataURLOptions;
|
||||
}) {
|
||||
const qrcode = ref('');
|
||||
|
||||
watch(
|
||||
[text, background, foreground, errorCorrectionLevel],
|
||||
[text, background, foreground, errorCorrectionLevel].filter(isRef),
|
||||
async () => {
|
||||
if (text.value)
|
||||
qrcode.value = await QRCode.toDataURL(text.value, {
|
||||
if (get(text))
|
||||
qrcode.value = await QRCode.toDataURL(get(text), {
|
||||
color: {
|
||||
dark: foreground.value,
|
||||
light: background.value,
|
||||
dark: get(foreground),
|
||||
light: get(background),
|
||||
...options?.color,
|
||||
},
|
||||
errorCorrectionLevel: errorCorrectionLevel.value,
|
||||
errorCorrectionLevel: get(errorCorrectionLevel) ?? 'M',
|
||||
...options,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -6,19 +6,23 @@ export function createToken({
|
||||
withNumbers = true,
|
||||
withSymbols = false,
|
||||
length = 64,
|
||||
alphabet,
|
||||
}: {
|
||||
withUppercase?: boolean;
|
||||
withLowercase?: boolean;
|
||||
withNumbers?: boolean;
|
||||
withSymbols?: boolean;
|
||||
length?: number;
|
||||
alphabet?: string;
|
||||
}) {
|
||||
const alphabet = [
|
||||
...(withUppercase ? 'ABCDEFGHIJKLMOPQRSTUVWXYZ' : ''),
|
||||
...(withLowercase ? 'abcdefghijklmopqrstuvwxyz' : ''),
|
||||
...(withNumbers ? '0123456789' : ''),
|
||||
...(withSymbols ? '.,;:!?./-"\'#{([-|\\@)]=}*+' : ''),
|
||||
].join('');
|
||||
const allAlphabet =
|
||||
alphabet ??
|
||||
[
|
||||
...(withUppercase ? 'ABCDEFGHIJKLMOPQRSTUVWXYZ' : ''),
|
||||
...(withLowercase ? 'abcdefghijklmopqrstuvwxyz' : ''),
|
||||
...(withNumbers ? '0123456789' : ''),
|
||||
...(withSymbols ? '.,;:!?./-"\'#{([-|\\@)]=}*+' : ''),
|
||||
].join('');
|
||||
|
||||
return shuffleString(alphabet.repeat(length)).substring(0, length);
|
||||
return shuffleString(allAlphabet.repeat(length)).substring(0, length);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user