Initial commit
What is version control? LOL
This commit is contained in:
commit
814448dbc9
21
.env.example
Normal file
21
.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
PORT="3000"
|
||||||
|
|
||||||
|
DB_HOST=
|
||||||
|
DB_PORT=
|
||||||
|
DB_USERNAME=
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_NAME=
|
||||||
|
|
||||||
|
ISSUER=
|
||||||
|
BS_SITE_URL=
|
||||||
|
SHARED_CLIENT_ID=
|
||||||
|
|
||||||
|
TOKEN_EXPIRES_IN_1="259200"
|
||||||
|
TOKEN_EXPIRES_IN_2="604800"
|
||||||
|
DEVICE_CODE_EXPIRES_IN="600"
|
||||||
|
GRANT_EXPIRES_IN="25920000"
|
||||||
|
|
||||||
|
# STOP HERE! You have completed all necessary editing.
|
||||||
|
# DO NOT edit the following lines UNLESS you know what you're doing.
|
||||||
|
|
||||||
|
DB_CONNECTION_STRING="mysql://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||||
62
.gitignore
vendored
Normal file
62
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Prisma Schema
|
||||||
|
prisma/schema.prisma
|
||||||
|
|
||||||
|
# Signing Key
|
||||||
|
oauth-private.key
|
||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "node-terminal",
|
||||||
|
"name": "Run Script: start:debug",
|
||||||
|
"request": "launch",
|
||||||
|
"command": "npm run start:debug",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
21
LICESNE
Normal file
21
LICESNE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025-present Blessing Skin Team
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
28
README.md
Normal file
28
README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Janus for Blessing Skin
|
||||||
|
|
||||||
|
Janus 是 [Blessing Skin Server](https://github.com/bs-community/blessing-skin-server) 的外挂 Yggdrasil Connect 服务端,基于 [NestJS](https://nestjs.com) 框架和 [oidc-provider](https://github.com/panva/node-oidc-provider) 库编写。
|
||||||
|
|
||||||
|
由于 Laravel 框架缺乏合适的 OpenID Connect 服务端扩展包,故采取这种外挂 OpenID Connect 服务端的方式为 Blessing Skin Server 实现 Yggdrasil Connect。
|
||||||
|
|
||||||
|
Janus 需要与 Blessing Skin Server 使用同一个 MySQL/MariaDB 数据库。不支持 PostgreSQL 和 SQLite 数据库。
|
||||||
|
|
||||||
|
## 环境需求
|
||||||
|
|
||||||
|
- Node.js >= 22
|
||||||
|
- 更低版本或许也可以,但未经测试
|
||||||
|
- Blessing Skin Server >= 6
|
||||||
|
- 需要安装 [Yggdrasil Connect](https://github.com/bs-community/blessing-skin-plugins/blob/master/plugins/yggdrasil-connect) 插件,可在插件市场中下载
|
||||||
|
- 该插件不需要也不可以与原版 Yggdrasil API 插件同时启用,但插件数据可以通用
|
||||||
|
- 在安装完该插件后,请务必阅读该插件的 [README](https://github.com/bs-community/blessing-skin-plugins/blob/master/plugins/yggdrasil-connect/README.md),了解如何配置该插件
|
||||||
|
|
||||||
|
## 部署指南
|
||||||
|
|
||||||
|
请查看 [Wiki - 部署指南](https://github.com/bs-community/janus/wiki/%E9%83%A8%E7%BD%B2%E6%8C%87%E5%8D%97)。
|
||||||
|
|
||||||
|
Janus **不是** 开箱即用的,需要手动构建。部署 Janus 并不难,但最好有一定的运维经验。
|
||||||
|
|
||||||
|
## 版权信息
|
||||||
|
|
||||||
|
Copyright 2025-present Blessing Skin Team. All rights reserved. Open source under the MIT license.
|
||||||
|
|
||||||
|
_Disclaimer:某站产品经理自己写代码的原则就是代码和人有一个能跑就行,自然有些代码很粗糙很难看很低效。如果你看着哪里的代码不爽,欢迎直接重构并 PR。_
|
||||||
34
eslint.config.mjs
Normal file
34
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
12862
package-lock.json
generated
Normal file
12862
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
80
package.json
Normal file
80
package.json
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
{
|
||||||
|
"name": "janus",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Standalone Yggdrasil Connect server for Blessing Skin.",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@prisma/client": "^6.10.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.2",
|
||||||
|
"nestjs-prisma": "^0.25.0",
|
||||||
|
"oidc-provider": "^9.1.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@swc/cli": "^0.6.0",
|
||||||
|
"@swc/core": "^1.10.7",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/oidc-provider": "^9.1.1",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^6.10.1",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
333
prisma/migrations/0_init/migration.sql
Normal file
333
prisma/migrations/0_init/migration.sql
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `code_id_to_uuid` (
|
||||||
|
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`code_id` VARCHAR(255) NOT NULL,
|
||||||
|
`uuid` VARCHAR(255) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
|
||||||
|
UNIQUE INDEX `code_id_to_uuid_code_id_unique`(`code_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `jobs` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`queue` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` LONGTEXT NOT NULL,
|
||||||
|
`attempts` TINYINT UNSIGNED NOT NULL,
|
||||||
|
`reserved_at` INTEGER UNSIGNED NULL,
|
||||||
|
`available_at` INTEGER UNSIGNED NOT NULL,
|
||||||
|
`created_at` INTEGER UNSIGNED NOT NULL,
|
||||||
|
|
||||||
|
INDEX `jobs_queue_index`(`queue`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `language_lines` (
|
||||||
|
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`group` VARCHAR(255) NOT NULL,
|
||||||
|
`key` VARCHAR(255) NOT NULL,
|
||||||
|
`text` TEXT NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL,
|
||||||
|
`updated_at` TIMESTAMP(0) NULL,
|
||||||
|
|
||||||
|
INDEX `language_lines_group_index`(`group`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `migrations` (
|
||||||
|
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`migration` VARCHAR(255) NOT NULL,
|
||||||
|
`batch` INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `notifications` (
|
||||||
|
`id` CHAR(36) NOT NULL,
|
||||||
|
`type` VARCHAR(255) NOT NULL,
|
||||||
|
`notifiable_type` VARCHAR(255) NOT NULL,
|
||||||
|
`notifiable_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`data` TEXT NOT NULL,
|
||||||
|
`read_at` TIMESTAMP(0) NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL,
|
||||||
|
`updated_at` TIMESTAMP(0) NULL,
|
||||||
|
|
||||||
|
INDEX `notifications_notifiable_type_notifiable_id_index`(`notifiable_type`, `notifiable_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `oauth_access_tokens` (
|
||||||
|
`id` VARCHAR(100) NOT NULL,
|
||||||
|
`user_id` BIGINT UNSIGNED NULL,
|
||||||
|
`client_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(255) NULL,
|
||||||
|
`scopes` TEXT NULL,
|
||||||
|
`revoked` BOOLEAN NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL,
|
||||||
|
`updated_at` TIMESTAMP(0) NULL,
|
||||||
|
`expires_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
INDEX `oauth_access_tokens_user_id_index`(`user_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `oauth_auth_codes` (
|
||||||
|
`id` VARCHAR(100) NOT NULL,
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`client_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`scopes` TEXT NULL,
|
||||||
|
`revoked` BOOLEAN NOT NULL,
|
||||||
|
`expires_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
INDEX `oauth_auth_codes_user_id_index`(`user_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `oauth_clients` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` BIGINT UNSIGNED NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`secret` VARCHAR(100) NULL,
|
||||||
|
`provider` VARCHAR(255) NULL,
|
||||||
|
`redirect` TEXT NOT NULL,
|
||||||
|
`personal_access_client` BOOLEAN NOT NULL,
|
||||||
|
`password_client` BOOLEAN NOT NULL,
|
||||||
|
`revoked` BOOLEAN NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL,
|
||||||
|
`updated_at` TIMESTAMP(0) NULL,
|
||||||
|
|
||||||
|
INDEX `oauth_clients_user_id_index`(`user_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `oauth_personal_access_clients` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`client_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL,
|
||||||
|
`updated_at` TIMESTAMP(0) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `oauth_refresh_tokens` (
|
||||||
|
`id` VARCHAR(100) NOT NULL,
|
||||||
|
`access_token_id` VARCHAR(100) NOT NULL,
|
||||||
|
`revoked` BOOLEAN NOT NULL,
|
||||||
|
`expires_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
INDEX `oauth_refresh_tokens_access_token_id_index`(`access_token_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `options` (
|
||||||
|
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`option_name` VARCHAR(50) NOT NULL,
|
||||||
|
`option_value` LONGTEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `players` (
|
||||||
|
`pid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`uid` INTEGER NOT NULL,
|
||||||
|
`name` VARCHAR(50) NOT NULL,
|
||||||
|
`tid_cape` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`last_modified` DATETIME(0) NOT NULL,
|
||||||
|
`tid_skin` INTEGER NOT NULL DEFAULT -1,
|
||||||
|
|
||||||
|
PRIMARY KEY (`pid`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `reports` (
|
||||||
|
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`tid` INTEGER NOT NULL,
|
||||||
|
`uploader` INTEGER NOT NULL,
|
||||||
|
`reporter` INTEGER NOT NULL,
|
||||||
|
`reason` LONGTEXT NOT NULL,
|
||||||
|
`status` INTEGER NOT NULL,
|
||||||
|
`report_at` DATETIME(0) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `scopes` (
|
||||||
|
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`description` VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `scopes_name_unique`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `textures` (
|
||||||
|
`tid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(50) NOT NULL,
|
||||||
|
`type` VARCHAR(10) NOT NULL,
|
||||||
|
`hash` VARCHAR(64) NOT NULL,
|
||||||
|
`size` INTEGER NOT NULL,
|
||||||
|
`uploader` INTEGER NOT NULL,
|
||||||
|
`public` TINYINT NOT NULL,
|
||||||
|
`upload_at` DATETIME(0) NOT NULL,
|
||||||
|
`likes` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
PRIMARY KEY (`tid`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `user_closet` (
|
||||||
|
`user_uid` INTEGER NOT NULL,
|
||||||
|
`texture_tid` INTEGER NOT NULL,
|
||||||
|
`item_name` TEXT NULL
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`uid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`email` VARCHAR(100) NOT NULL,
|
||||||
|
`nickname` VARCHAR(50) NOT NULL DEFAULT '',
|
||||||
|
`locale` VARCHAR(255) NULL,
|
||||||
|
`score` INTEGER NOT NULL,
|
||||||
|
`avatar` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`password` VARCHAR(255) NOT NULL,
|
||||||
|
`ip` VARCHAR(45) NOT NULL,
|
||||||
|
`is_dark_mode` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`permission` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`last_sign_at` DATETIME(0) NOT NULL,
|
||||||
|
`register_at` DATETIME(0) NOT NULL,
|
||||||
|
`verified` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`verification_token` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`remember_token` VARCHAR(100) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`uid`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `uuid` (
|
||||||
|
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`pid` INTEGER UNSIGNED NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`uuid` VARCHAR(255) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL,
|
||||||
|
`updated_at` TIMESTAMP(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `uuid_pid_unique`(`pid`),
|
||||||
|
UNIQUE INDEX `uuid_name_unique`(`name`),
|
||||||
|
UNIQUE INDEX `uuid_uuid_unique`(`uuid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ygg_log` (
|
||||||
|
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`action` VARCHAR(255) NOT NULL,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`player_id` INTEGER NOT NULL,
|
||||||
|
`parameters` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||||
|
`ip` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`time` DATETIME(0) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_authorization_codes` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_authorization_codes_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_authorization_codes_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_device_codes` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`userCode` VARCHAR(191) NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_device_codes_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_device_codes_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_refresh_tokens` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_refresh_tokens_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_refresh_tokens_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_grants` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_grants_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_grants_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_interactions` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_interactions_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_interactions_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_sessions` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_sessions_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_sessions_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `code_id_to_uuid` ADD CONSTRAINT `code_id_to_uuid_code_id_foreign` FOREIGN KEY (`code_id`) REFERENCES `oauth_auth_codes`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `uuid` ADD CONSTRAINT `uuid_pid_foreign` FOREIGN KEY (`pid`) REFERENCES `players`(`pid`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
81
prisma/migrations/20250618204012_init_janus/migration.sql
Normal file
81
prisma/migrations/20250618204012_init_janus/migration.sql
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_authorization_codes` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_authorization_codes_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_authorization_codes_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_device_codes` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`userCode` VARCHAR(191) NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_device_codes_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_device_codes_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_refresh_tokens` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_refresh_tokens_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_refresh_tokens_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_grants` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_grants_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_grants_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_interactions` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_interactions_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_interactions_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `yggc_sessions` (
|
||||||
|
`id` VARCHAR(255) NOT NULL,
|
||||||
|
`payload` JSON NOT NULL,
|
||||||
|
`uid` VARCHAR(255) NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `yggc_sessions_id_key`(`id`),
|
||||||
|
UNIQUE INDEX `yggc_sessions_uid_key`(`uid`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "mysql"
|
||||||
310
prisma/schema.prisma.example
Normal file
310
prisma/schema.prisma.example
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DB_CONNECTION_STRING")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CodeIdToUUID {
|
||||||
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
code_id String @unique(map: "code_id_to_uuid_code_id_unique") @db.VarChar(255)
|
||||||
|
uuid String @db.VarChar(255)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
code PassportAuthCode @relation(fields: [code_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "code_id_to_uuid_code_id_foreign")
|
||||||
|
|
||||||
|
@@map("code_id_to_uuid")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PassportAccessToken {
|
||||||
|
id String @id @db.VarChar(100)
|
||||||
|
user_id BigInt? @db.UnsignedBigInt
|
||||||
|
client_id BigInt @db.UnsignedBigInt
|
||||||
|
name String? @db.VarChar(255)
|
||||||
|
scopes String? @db.Text
|
||||||
|
revoked Boolean
|
||||||
|
created_at DateTime? @db.Timestamp(0)
|
||||||
|
updated_at DateTime? @db.Timestamp(0)
|
||||||
|
expires_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
@@index([user_id], map: "oauth_access_tokens_user_id_index")
|
||||||
|
@@map("oauth_access_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PassportAuthCode {
|
||||||
|
id String @id @db.VarChar(100)
|
||||||
|
user_id BigInt @db.UnsignedBigInt
|
||||||
|
client_id BigInt @db.UnsignedBigInt
|
||||||
|
scopes String? @db.Text
|
||||||
|
revoked Boolean
|
||||||
|
expires_at DateTime? @db.DateTime(0)
|
||||||
|
CodeIdToUUID CodeIdToUUID?
|
||||||
|
|
||||||
|
@@index([user_id], map: "oauth_auth_codes_user_id_index")
|
||||||
|
@@map("oauth_auth_codes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PassportRefreshToken {
|
||||||
|
id String @id @db.VarChar(100)
|
||||||
|
access_token_id String @db.VarChar(100)
|
||||||
|
revoked Boolean
|
||||||
|
expires_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
@@index([access_token_id], map: "oauth_refresh_tokens_access_token_id_index")
|
||||||
|
@@map("oauth_refresh_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Client {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
user_id BigInt? @db.UnsignedBigInt
|
||||||
|
client_name String @map("name") @db.VarChar(255)
|
||||||
|
client_secret String? @map("secret") @db.VarChar(100)
|
||||||
|
provider String? @db.VarChar(255)
|
||||||
|
redirect String @db.Text
|
||||||
|
personal_access_client Boolean
|
||||||
|
password_client Boolean
|
||||||
|
revoked Boolean
|
||||||
|
created_at DateTime? @db.Timestamp(0)
|
||||||
|
updated_at DateTime? @db.Timestamp(0)
|
||||||
|
|
||||||
|
@@index([user_id], map: "oauth_clients_user_id_index")
|
||||||
|
@@map("oauth_clients")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Option {
|
||||||
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
name String @map("option_name") @db.VarChar(50)
|
||||||
|
value String @map("option_value") @db.LongText
|
||||||
|
|
||||||
|
@@map("options")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Player {
|
||||||
|
pid Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
uid Int
|
||||||
|
name String @db.VarChar(50)
|
||||||
|
tid_cape Int @default(0)
|
||||||
|
last_modified DateTime @db.DateTime(0)
|
||||||
|
tid_skin Int @default(-1)
|
||||||
|
uuid UUID?
|
||||||
|
|
||||||
|
@@map("players")
|
||||||
|
}
|
||||||
|
|
||||||
|
model reports {
|
||||||
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
tid Int
|
||||||
|
uploader Int
|
||||||
|
reporter Int
|
||||||
|
reason String @db.LongText
|
||||||
|
status Int
|
||||||
|
report_at DateTime @db.DateTime(0)
|
||||||
|
|
||||||
|
@@map("reports")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
model scopes {
|
||||||
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
name String @unique(map: "scopes_name_unique") @db.VarChar(255)
|
||||||
|
description String @db.VarChar(255)
|
||||||
|
|
||||||
|
@@map("scopes")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
uid Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
email String @db.VarChar(100)
|
||||||
|
nickname String @default("") @db.VarChar(50)
|
||||||
|
locale String? @db.VarChar(255)
|
||||||
|
score Int
|
||||||
|
avatar Int @default(0)
|
||||||
|
password String @db.VarChar(255)
|
||||||
|
ip String @db.VarChar(45)
|
||||||
|
is_dark_mode Boolean @default(false)
|
||||||
|
permission Int @default(0)
|
||||||
|
last_sign_at DateTime @db.DateTime(0)
|
||||||
|
register_at DateTime @db.DateTime(0)
|
||||||
|
verified Boolean @default(false)
|
||||||
|
verification_token String @default("") @db.VarChar(255)
|
||||||
|
remember_token String? @db.VarChar(100)
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model UUID {
|
||||||
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
pid Int? @unique(map: "uuid_pid_unique") @db.UnsignedInt
|
||||||
|
name String @unique(map: "uuid_name_unique") @db.VarChar(255)
|
||||||
|
uuid String @unique(map: "uuid_uuid_unique") @db.VarChar(255)
|
||||||
|
created_at DateTime? @db.Timestamp(0)
|
||||||
|
updated_at DateTime? @db.Timestamp(0)
|
||||||
|
player Player? @relation(fields: [pid], references: [pid], onDelete: Cascade, onUpdate: NoAction, map: "uuid_pid_foreign")
|
||||||
|
|
||||||
|
@@map("uuid")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthorizationCode {
|
||||||
|
id String @id @unique @db.VarChar(255)
|
||||||
|
payload Json
|
||||||
|
uid String? @unique @db.VarChar(255)
|
||||||
|
consumed Boolean @default(false)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("yggc_authorization_codes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DeviceCode {
|
||||||
|
id String @id @unique @db.VarChar(255)
|
||||||
|
payload Json
|
||||||
|
userCode String?
|
||||||
|
uid String? @unique @db.VarChar(255)
|
||||||
|
consumed Boolean @default(false)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("yggc_device_codes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RefreshToken {
|
||||||
|
id String @id @unique @db.VarChar(255)
|
||||||
|
payload Json
|
||||||
|
uid String? @unique @db.VarChar(255)
|
||||||
|
consumed Boolean @default(false)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("yggc_refresh_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Grant {
|
||||||
|
id String @id @unique @db.VarChar(255)
|
||||||
|
payload Json
|
||||||
|
uid String? @unique @db.VarChar(255)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("yggc_grants")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Interaction {
|
||||||
|
id String @id @unique @db.VarChar(255)
|
||||||
|
payload Json
|
||||||
|
uid String? @unique @db.VarChar(255)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("yggc_interactions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @unique @db.VarChar(255)
|
||||||
|
payload Json
|
||||||
|
uid String? @unique @db.VarChar(255)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("yggc_sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model jobs {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
queue String @db.VarChar(255)
|
||||||
|
payload String @db.LongText
|
||||||
|
attempts Int @db.UnsignedTinyInt
|
||||||
|
reserved_at Int? @db.UnsignedInt
|
||||||
|
available_at Int @db.UnsignedInt
|
||||||
|
created_at Int @db.UnsignedInt
|
||||||
|
|
||||||
|
@@index([queue], map: "jobs_queue_index")
|
||||||
|
@@map("jobs")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
model language_lines {
|
||||||
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
group String @db.VarChar(255)
|
||||||
|
key String @db.VarChar(255)
|
||||||
|
text String @db.Text
|
||||||
|
created_at DateTime? @db.Timestamp(0)
|
||||||
|
updated_at DateTime? @db.Timestamp(0)
|
||||||
|
|
||||||
|
@@index([group], map: "language_lines_group_index")
|
||||||
|
@@map("language_lines")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
model migrations {
|
||||||
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
migration String @db.VarChar(255)
|
||||||
|
batch Int
|
||||||
|
|
||||||
|
@@map("migrations")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
model notifications {
|
||||||
|
id String @id @db.Char(36)
|
||||||
|
type String @db.VarChar(255)
|
||||||
|
notifiable_type String @db.VarChar(255)
|
||||||
|
notifiable_id BigInt @db.UnsignedBigInt
|
||||||
|
data String @db.Text
|
||||||
|
read_at DateTime? @db.Timestamp(0)
|
||||||
|
created_at DateTime? @db.Timestamp(0)
|
||||||
|
updated_at DateTime? @db.Timestamp(0)
|
||||||
|
|
||||||
|
@@index([notifiable_type, notifiable_id], map: "notifications_notifiable_type_notifiable_id_index")
|
||||||
|
@@map("notifications")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
model ygg_log {
|
||||||
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
action String @db.VarChar(255)
|
||||||
|
user_id Int
|
||||||
|
player_id Int
|
||||||
|
parameters String @default("") @db.VarChar(2048)
|
||||||
|
ip String @default("") @db.VarChar(255)
|
||||||
|
time DateTime @db.DateTime(0)
|
||||||
|
|
||||||
|
@@map("ygg_log")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
model oauth_personal_access_clients {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
client_id BigInt @db.UnsignedBigInt
|
||||||
|
created_at DateTime? @db.Timestamp(0)
|
||||||
|
updated_at DateTime? @db.Timestamp(0)
|
||||||
|
|
||||||
|
@@map("oauth_personal_access_clients")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
model textures {
|
||||||
|
tid Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
|
name String @db.VarChar(50)
|
||||||
|
type String @db.VarChar(10)
|
||||||
|
hash String @db.VarChar(64)
|
||||||
|
size Int
|
||||||
|
uploader Int
|
||||||
|
public Int @db.TinyInt
|
||||||
|
upload_at DateTime @db.DateTime(0)
|
||||||
|
likes Int @default(0) @db.UnsignedInt
|
||||||
|
|
||||||
|
@@map("textures")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client.
|
||||||
|
model user_closet {
|
||||||
|
user_uid Int
|
||||||
|
texture_tid Int
|
||||||
|
item_name String? @db.Text
|
||||||
|
|
||||||
|
@@map("user_closet")
|
||||||
|
@@ignore
|
||||||
|
}
|
||||||
40
src/app.controller.ts
Normal file
40
src/app.controller.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Controller, All, Req, Res, Get, Redirect } from '@nestjs/common';
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) { }
|
||||||
|
|
||||||
|
@Get("/interaction/:uid")
|
||||||
|
async interaction(@Req() req: Request, @Res() res: Response): Promise<object | void> {
|
||||||
|
return this.appService.interaction(req, res);
|
||||||
|
}
|
||||||
|
@Get("/interaction/:uid/callback")
|
||||||
|
async interactionLogin(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
|
return this.appService.interactionCallback(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("/userinfo")
|
||||||
|
@Redirect("", 303)
|
||||||
|
async userinfo(): Promise<object> {
|
||||||
|
return {
|
||||||
|
url: this.appService.getSiteUrl() + "/yggc/userinfo",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* @Get('/device')
|
||||||
|
async userCodeVerification(@Req() req: Request, @Res() res: Response, @Query('xsrf') query: string): Promise<void | object> {
|
||||||
|
if(query != undefined) {
|
||||||
|
return this.appService.callback(req, res);
|
||||||
|
} else {
|
||||||
|
const url = this.appService.getSiteUrl() + "/yggc/device";
|
||||||
|
res.redirect(HttpStatus.SEE_OTHER, url);
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
@All("/*")
|
||||||
|
getHello(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
|
return this.appService.callback(req, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app.module.ts
Normal file
38
src/app.module.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
import { CustomPrismaModule } from 'nestjs-prisma';
|
||||||
|
import { EXTENDED_PRISMA_SERVICE, getExtendedPrismaClient } from './extended-prisma-client';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { OIDCProviderService } from './oidc-provider.service';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { importPKCS8, exportJWK, JWK } from 'jose';
|
||||||
|
import { validate } from './env-validation';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true, validate: validate }),
|
||||||
|
CustomPrismaModule.forRootAsync({
|
||||||
|
name: EXTENDED_PRISMA_SERVICE,
|
||||||
|
useFactory: (config: ConfigService) => { // SO FUCKING STUPID, someone refactor this please
|
||||||
|
return getExtendedPrismaClient(config.get<string>('BS_SITE_URL')!);
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [
|
||||||
|
AppService,
|
||||||
|
{
|
||||||
|
provide: "JWK",
|
||||||
|
useFactory: async (): Promise<JWK> => {
|
||||||
|
const pkcs8: string = readFileSync("./oauth-private.key", 'utf8');
|
||||||
|
const keyImported: CryptoKey = await importPKCS8(pkcs8, 'RS256', { extractable: true });
|
||||||
|
return exportJWK(keyImported);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OIDCProviderService
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule { }
|
||||||
|
|
||||||
155
src/app.service.ts
Normal file
155
src/app.service.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import * as oidc from 'oidc-provider';
|
||||||
|
import { BS_RESOURCE_INDICATOR, OIDCProviderService } from './oidc-provider.service';
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { CustomPrismaService } from 'nestjs-prisma';
|
||||||
|
import { EXTENDED_PRISMA_SERVICE, ExtendedPrismaClient } from './extended-prisma-client';
|
||||||
|
import { getDateWithTimezoneOffset } from './helper';
|
||||||
|
import { CodeIdToUUID, PassportAuthCode, User } from '@prisma/client';
|
||||||
|
import { YggCScopes } from './blessing.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
|
||||||
|
readonly callback: (req: Request, res: Response) => Promise<void>;
|
||||||
|
private readonly provider: oidc.Provider;
|
||||||
|
private readonly siteUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(EXTENDED_PRISMA_SERVICE) private readonly prisma: CustomPrismaService<ExtendedPrismaClient>,
|
||||||
|
private readonly oidcProvider: OIDCProviderService,
|
||||||
|
) {
|
||||||
|
this.provider = this.oidcProvider.provider;
|
||||||
|
this.siteUrl = this.oidcProvider.siteUrl;
|
||||||
|
this.callback = this.provider.callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
async interaction(req: Request, res: Response): Promise<object | void> {
|
||||||
|
|
||||||
|
const result = await this.provider.interactionDetails(req, res);
|
||||||
|
|
||||||
|
const { params } = result;
|
||||||
|
const url = `${this.siteUrl}/oauth/authorize?client_id=${params.client_id}&response_type=code&scope=${params.scope}&redirect_uri=${this.siteUrl}/yggc/callback&state=${result.jti}&prompt=consent`
|
||||||
|
|
||||||
|
/* if(result.deviceCode) {
|
||||||
|
return res.json({
|
||||||
|
url: url
|
||||||
|
});
|
||||||
|
} */
|
||||||
|
|
||||||
|
return res.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async interactionCallback(req: Request, res: Response): Promise<void> {
|
||||||
|
const { code, error, error_description } = req.query as { code: string; error: string | undefined; error_description: string | undefined; };
|
||||||
|
const { client_id, scope } = (await this.provider.interactionDetails(req, res)).params as { client_id: string; scope: string; };
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return this.provider.interactionFinished(req, res, {
|
||||||
|
error: error,
|
||||||
|
error_description: error_description
|
||||||
|
}, { mergeWithLastSubmission: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authCode: PassportAuthCode | null = await this.prisma.client.passportAuthCode.findFirst({
|
||||||
|
where: {
|
||||||
|
id: code,
|
||||||
|
revoked: false,
|
||||||
|
expires_at: {
|
||||||
|
gte: getDateWithTimezoneOffset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authCode) {
|
||||||
|
return this.provider.interactionFinished(req, res, {
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Invalid or expired code"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user: User | null = await this.prisma.client.user.findFirst({
|
||||||
|
where: {
|
||||||
|
uid: Number(authCode?.user_id),
|
||||||
|
verified: true,
|
||||||
|
permission: {
|
||||||
|
not: -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return this.provider.interactionFinished(req, res, {
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Invalid or expired code"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopesInSession: Set<string> = new Set(scope.split(" "));
|
||||||
|
const scopesInBSAuth: Set<string> = new Set(scopesInSession);
|
||||||
|
const scopes = scopesInSession.intersection(scopesInBSAuth);
|
||||||
|
|
||||||
|
if (scopes.has(YggCScopes.PROFILE_SELECT)) {
|
||||||
|
const codeIdToUUID: CodeIdToUUID | null = await this.prisma.client.codeIdToUUID.findFirst({
|
||||||
|
where: {
|
||||||
|
code_id: code
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!codeIdToUUID) {
|
||||||
|
return this.provider.interactionFinished(req, res, {
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Invalid or expired code"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const uuid = await this.prisma.client.uUID.findFirst({
|
||||||
|
where: {
|
||||||
|
uuid: codeIdToUUID.uuid
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
player: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uuid) {
|
||||||
|
return this.provider.interactionFinished(req, res, {
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Invalid or expired code"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grant: oidc.Grant = new this.provider.Grant({
|
||||||
|
clientId: client_id,
|
||||||
|
accountId: user.uid.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
scopes.forEach((scope) => {
|
||||||
|
grant.addOIDCScope(scope);
|
||||||
|
grant.addResourceScope(BS_RESOURCE_INDICATOR, scope);
|
||||||
|
});
|
||||||
|
|
||||||
|
grant.jti = code;
|
||||||
|
await grant.save();
|
||||||
|
|
||||||
|
await this.prisma.client.passportAuthCode.update({
|
||||||
|
where: {
|
||||||
|
id: code
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
revoked: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.provider.interactionFinished(req, res, {
|
||||||
|
login: {
|
||||||
|
accountId: user.uid.toString()
|
||||||
|
},
|
||||||
|
consent: {
|
||||||
|
grantId: code
|
||||||
|
}
|
||||||
|
}, { mergeWithLastSubmission: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
getSiteUrl(): string {
|
||||||
|
return this.siteUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/blessing.types.ts
Normal file
41
src/blessing.types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export type YggdrasilProfile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserInfo = {
|
||||||
|
sub: string;
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
picture?: string;
|
||||||
|
selectedProfile?: YggdrasilProfile;
|
||||||
|
availableProfiles?: YggdrasilProfile[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum YggCScopes {
|
||||||
|
OPENID = 'openid',
|
||||||
|
PROFILE = 'profile',
|
||||||
|
EMAIL = 'email',
|
||||||
|
PROFILE_SELECT = 'Yggdrasil.PlayerProfiles.Select',
|
||||||
|
PROFILE_READ = 'Yggdrasil.PlayerProfiles.Read',
|
||||||
|
SERVER_JOIN = 'Yggdrasil.Server.Join'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum YggCClaims {
|
||||||
|
NICKNAME = 'nickname',
|
||||||
|
PICTURE = 'picture',
|
||||||
|
EMAIL = 'email',
|
||||||
|
EMAIL_VERIFIED = 'email_verified',
|
||||||
|
SELECTED_PROFILE = 'selectedProfile',
|
||||||
|
AVAILABLE_PROFILES = 'availableProfiles'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BS_RESOURCE_INDICATOR: string = "https://github.com/bs-community/blessing-skin-server";
|
||||||
|
export const ACCESS_TOKEN_NAME: string = "Yggdrasil Connect";
|
||||||
|
|
||||||
|
export enum RoutesWithoutCORS {
|
||||||
|
'auth',
|
||||||
|
'device',
|
||||||
|
'interaction'
|
||||||
|
}
|
||||||
89
src/env-validation.ts
Normal file
89
src/env-validation.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
import { IsNotEmpty, IsNumber, IsString, IsUrl, Max, Min, registerDecorator, validateSync, ValidationArguments, ValidationOptions } from "class-validator";
|
||||||
|
|
||||||
|
class Env {
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(65535)
|
||||||
|
PORT: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DB_HOST: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(65535)
|
||||||
|
DB_PORT: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DB_USERNAME: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DB_PASSWORD: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
DB_NAME: string;
|
||||||
|
|
||||||
|
@IsUrl({ require_tld: false })
|
||||||
|
@IsSecureUrl()
|
||||||
|
ISSUER: string;
|
||||||
|
|
||||||
|
@IsUrl({ require_tld: false })
|
||||||
|
@IsSecureUrl()
|
||||||
|
BS_SITE_URL: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
SHARED_CLIENT_ID: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
TOKEN_EXPIRES_IN_1: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
TOKEN_EXPIRES_IN_2: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
DEVICE_CODE_EXPIRES_IN: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
GRANT_EXPIRES_IN: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IsSecureUrl(property?: string, validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: Object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
name: 'isSecureUrl',
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName: propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [property],
|
||||||
|
validator: {
|
||||||
|
validate(value: string, args: ValidationArguments) {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (url.protocol === 'https:' || url.hostname == 'localhost') && !url.search.length && !url.hash.length && !value.endsWith('/');
|
||||||
|
},
|
||||||
|
defaultMessage(args: ValidationArguments) {
|
||||||
|
return `${args.property} must be a secure URL (either localhost or HTTPS), not end with a slash, and not contain queries and fragments.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate(config: Record<string, unknown>) {
|
||||||
|
const validatedConfig = plainToInstance(
|
||||||
|
Env,
|
||||||
|
config,
|
||||||
|
{ enableImplicitConversion: true },
|
||||||
|
);
|
||||||
|
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(errors.toString());
|
||||||
|
}
|
||||||
|
return validatedConfig;
|
||||||
|
}
|
||||||
53
src/extended-prisma-client.ts
Normal file
53
src/extended-prisma-client.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export function getExtendedPrismaClient(siteUrl: string) {
|
||||||
|
const extendedPrismaClient = new PrismaClient().$extends({
|
||||||
|
result: {
|
||||||
|
client: {
|
||||||
|
client_id: {
|
||||||
|
needs: { id: true },
|
||||||
|
compute(data: { id: bigint; }) {
|
||||||
|
return data.id.toString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
redirect_uris: {
|
||||||
|
needs: { redirect: true },
|
||||||
|
compute(data: { redirect: string; }) {
|
||||||
|
const splitted: string[] = data.redirect.split(',').map((uri: string) => uri.trim());
|
||||||
|
return splitted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).$extends({
|
||||||
|
result: {
|
||||||
|
client: {
|
||||||
|
token_endpoint_auth_method: {
|
||||||
|
needs: { redirect_uris: true },
|
||||||
|
compute: (data: { redirect_uris: string[] }) => {
|
||||||
|
if(data.redirect_uris.indexOf(`${siteUrl}/yggc/client/public`) !== -1) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).$extends({
|
||||||
|
result: {
|
||||||
|
$allModels: {
|
||||||
|
payload: {
|
||||||
|
compute(data: object | undefined) {
|
||||||
|
if (data != undefined) {
|
||||||
|
return data.hasOwnProperty('payload') ? data['payload'] : data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return extendedPrismaClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtendedPrismaClient = ReturnType<typeof getExtendedPrismaClient>;
|
||||||
|
export const EXTENDED_PRISMA_SERVICE = 'EXTENDED_PRISMA_SERVICE';
|
||||||
3
src/helper.ts
Normal file
3
src/helper.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function getDateWithTimezoneOffset(): Date {
|
||||||
|
return new Date(Date.now() - new Date().getTimezoneOffset() * 60 * 1000);
|
||||||
|
}
|
||||||
30
src/main.ts
Normal file
30
src/main.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { RoutesWithoutCORS } from './blessing.types';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
BigInt.prototype['toJSON'] = function () {
|
||||||
|
const int = Number.parseInt(this.toString());
|
||||||
|
return int ?? this.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
|
/*
|
||||||
|
> The Token Endpoint ... and any other endpoints directly accessed by Clients SHOULD support the use of Cross-Origin Resource Sharing (CORS) ...
|
||||||
|
> The use of CORS at the Authorization Endpoint is NOT RECOMMENDED as it is redirected to by the client and not directly accessed.
|
||||||
|
https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||||
|
*/
|
||||||
|
app.enableCors((req: Request, callback) => {
|
||||||
|
callback(null, {
|
||||||
|
origin: req.path.split('/', 2)[1] in RoutesWithoutCORS ? false : '*'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.enable('trust proxy');
|
||||||
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
136
src/oidc-adapter.ts
Normal file
136
src/oidc-adapter.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { Adapter, AdapterPayload, AdapterFactory } from 'oidc-provider';
|
||||||
|
import { ExtendedPrismaClient } from './extended-prisma-client';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export type ModelType =
|
||||||
|
"Grant" |
|
||||||
|
"Session" |
|
||||||
|
"AccessToken" |
|
||||||
|
"AuthorizationCode" |
|
||||||
|
"RefreshToken" |
|
||||||
|
"ClientCredentials" |
|
||||||
|
"Client" |
|
||||||
|
"InitialAccessToken" |
|
||||||
|
"RegistrationAccessToken" |
|
||||||
|
"DeviceCode" |
|
||||||
|
"Interaction" |
|
||||||
|
"ReplayDetection" |
|
||||||
|
"BackchannelAuthenticationRequest" |
|
||||||
|
"PushedAuthorizationRequest";
|
||||||
|
|
||||||
|
export class OIDCAdapter implements Adapter {
|
||||||
|
|
||||||
|
private readonly model: string;
|
||||||
|
|
||||||
|
constructor(private readonly type: ModelType, private readonly prisma: ExtendedPrismaClient, private readonly config: ConfigService) {
|
||||||
|
this.model = type.charAt(0).toLowerCase() + type.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getAdapterFactory(prisma: ExtendedPrismaClient, config: ConfigService): AdapterFactory {
|
||||||
|
return (type: ModelType) => {
|
||||||
|
return new OIDCAdapter(type, prisma, config);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(id: string, payload: AdapterPayload): Promise<any> {
|
||||||
|
const data = {
|
||||||
|
uid: payload.uid,
|
||||||
|
payload: payload,
|
||||||
|
userCode: payload.userCode
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma[this.model].upsert({
|
||||||
|
where: {
|
||||||
|
id: id
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: id,
|
||||||
|
...data
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...data
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(id: string): Promise<AdapterPayload | undefined> {
|
||||||
|
|
||||||
|
const data = await this.prisma[this.model].findFirst({
|
||||||
|
where: {
|
||||||
|
id: this.type == 'Client' ? parseInt(id) : id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = data.payload?.accountId;
|
||||||
|
if (accountId) {
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
uid: parseInt(accountId)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserCode(userCode: string): Promise<AdapterPayload | undefined> {
|
||||||
|
const data = await this.prisma.deviceCode.findFirst({
|
||||||
|
where: {
|
||||||
|
userCode: userCode
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
return data.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUid(uid: string): Promise<AdapterPayload | undefined> {
|
||||||
|
const data = await this.prisma[this.model].findFirst({
|
||||||
|
where: {
|
||||||
|
uid: uid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async consume(id: string): Promise<void> {
|
||||||
|
await this.prisma[this.model].update({
|
||||||
|
where: {
|
||||||
|
id: id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
consumed: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(id: string): Promise<void> {
|
||||||
|
await this.prisma[this.model].deleteMany({
|
||||||
|
where: {
|
||||||
|
id: id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeByGrantId(grantId: string): Promise<undefined | void> {
|
||||||
|
await this.prisma.grant.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: grantId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
411
src/oidc-provider.service.ts
Normal file
411
src/oidc-provider.service.ts
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
import * as oidc from 'oidc-provider';
|
||||||
|
import { OIDCAdapter } from './oidc-adapter';
|
||||||
|
import { EXTENDED_PRISMA_SERVICE, ExtendedPrismaClient } from './extended-prisma-client';
|
||||||
|
import { JWK } from 'jose';
|
||||||
|
import { getDateWithTimezoneOffset } from './helper';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { UserInfo, YggCClaims, YggCScopes, YggdrasilProfile } from './blessing.types';
|
||||||
|
import { CodeIdToUUID, PassportAccessToken, Player, UUID } from '@prisma/client';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { CustomPrismaService } from 'nestjs-prisma';
|
||||||
|
|
||||||
|
export const BS_RESOURCE_INDICATOR: string = "https://github.com/bs-community/blessing-skin-server";
|
||||||
|
export const ACCESS_TOKEN_NAME: string = "Yggdrasil Connect";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OIDCProviderService {
|
||||||
|
readonly provider: oidc.Provider;
|
||||||
|
readonly siteUrl: string;
|
||||||
|
readonly session: oidc.Session;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@Inject(EXTENDED_PRISMA_SERVICE) private readonly prisma: CustomPrismaService<ExtendedPrismaClient>,
|
||||||
|
@Inject("JWK") private readonly jwk: JWK,
|
||||||
|
) {
|
||||||
|
const siteUrl: string = this.config.get<string>("BS_SITE_URL")!;
|
||||||
|
const issuer: string = this.config.get<string>("ISSUER")!;
|
||||||
|
const tokenExpiresIn1: number = this.config.get<number>("TOKEN_EXPIRES_IN_1")!;
|
||||||
|
const tokenExpiresIn2: number = this.config.get<number>("TOKEN_EXPIRES_IN_2")!;
|
||||||
|
const deviceCodeExpiresIn: number = this.config.get<number>("DEVICE_CODE_EXPIRES_IN")!;
|
||||||
|
const grantExpiresIn: number = this.config.get<number>("GRANT_EXPIRES_IN")!;
|
||||||
|
const sharedClientId: string | undefined = this.config.get<string>("SHARED_CLIENT_ID");
|
||||||
|
|
||||||
|
const basePolicy = oidc.interactionPolicy.base();
|
||||||
|
const loginPrompt = basePolicy.get('login')!;
|
||||||
|
const consentPrompt = basePolicy.get('consent')!;
|
||||||
|
const grantPrompt = new oidc.interactionPolicy.Prompt({ name: 'grant', requestable: true }, new oidc.interactionPolicy.Check('grant', 'invalid grant', (ctx) => {
|
||||||
|
const oidcContext = ctx.oidc;
|
||||||
|
if (!oidcContext.entities.Grant) {
|
||||||
|
return oidc.interactionPolicy.Check.REQUEST_PROMPT;
|
||||||
|
}
|
||||||
|
return oidc.interactionPolicy.Check.NO_NEED_TO_PROMPT;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const provider = new oidc.Provider(issuer, {
|
||||||
|
adapter: OIDCAdapter.getAdapterFactory(this.prisma.client, this.config),
|
||||||
|
jwks: {
|
||||||
|
keys: [this.jwk]
|
||||||
|
},
|
||||||
|
clientAuthMethods: [
|
||||||
|
'client_secret_post',
|
||||||
|
'none'
|
||||||
|
],
|
||||||
|
responseTypes: ['code', 'id_token', 'code id_token'],
|
||||||
|
claims: {
|
||||||
|
[YggCScopes.PROFILE]: [YggCClaims.NICKNAME, YggCClaims.PICTURE],
|
||||||
|
[YggCScopes.EMAIL]: [YggCClaims.EMAIL, YggCClaims.EMAIL_VERIFIED],
|
||||||
|
[YggCScopes.PROFILE_SELECT]: [YggCClaims.SELECTED_PROFILE],
|
||||||
|
[YggCScopes.PROFILE_READ]: [YggCClaims.AVAILABLE_PROFILES]
|
||||||
|
},
|
||||||
|
scopes: [
|
||||||
|
YggCScopes.EMAIL,
|
||||||
|
YggCScopes.PROFILE,
|
||||||
|
YggCScopes.PROFILE_SELECT,
|
||||||
|
YggCScopes.PROFILE_READ,
|
||||||
|
YggCScopes.SERVER_JOIN,
|
||||||
|
'offline_access',
|
||||||
|
'openid'
|
||||||
|
],
|
||||||
|
async loadExistingGrant(ctx: oidc.KoaContextWithOIDC) {
|
||||||
|
const grantId = ctx.oidc.result?.consent?.grantId;
|
||||||
|
if (grantId) {
|
||||||
|
return ctx.oidc.provider.Grant.find(grantId);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
findAccount: this.findAccount.bind(this),
|
||||||
|
conformIdTokenClaims: false,
|
||||||
|
features: {
|
||||||
|
deviceFlow: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
dPoP: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
devInteractions: { enabled: false },
|
||||||
|
resourceIndicators: {
|
||||||
|
enabled: true,
|
||||||
|
defaultResource(ctx: oidc.KoaContextWithOIDC, client: oidc.Client, oneOf: string[] | undefined) {
|
||||||
|
return BS_RESOURCE_INDICATOR;
|
||||||
|
},
|
||||||
|
getResourceServerInfo(ctx: oidc.KoaContextWithOIDC, resourceIndicator: string, client: oidc.Client) {
|
||||||
|
return {
|
||||||
|
scope: Array.from(ctx.oidc.requestParamScopes).join(' '),
|
||||||
|
audience: BS_RESOURCE_INDICATOR,
|
||||||
|
accessTokenFormat: 'jwt',
|
||||||
|
jwt: {
|
||||||
|
sign: { alg: 'RS256' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
useGrantedResource(ctx: oidc.KoaContextWithOIDC, model) {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pushedAuthorizationRequests: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
rpInitiatedLogout: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formats: {
|
||||||
|
customizers: {
|
||||||
|
async jwt(ctx, token, jwt) {
|
||||||
|
jwt.payload.aud = ctx.oidc.client!.clientId;
|
||||||
|
return jwt;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interactions: {
|
||||||
|
policy: [
|
||||||
|
loginPrompt,
|
||||||
|
grantPrompt,
|
||||||
|
consentPrompt
|
||||||
|
],
|
||||||
|
url(ctx, interaction) {
|
||||||
|
const prompt = interaction.prompt;
|
||||||
|
return `/interaction/${interaction.uid}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async extraTokenClaims(ctx: oidc.KoaContextWithOIDC, token: oidc.AccessToken) {
|
||||||
|
const oidcContext = ctx.oidc;
|
||||||
|
const claims: oidc.AccountClaims | undefined = await oidcContext.account?.claims('id_token', token.scope!, {}, []);
|
||||||
|
const selectedProfile = claims?.selectedProfile as YggdrasilProfile | undefined;
|
||||||
|
return {
|
||||||
|
selectedProfile: selectedProfile?.id,
|
||||||
|
scopes: Array.from(oidcContext.entities.RefreshToken?.scopes ?? token.scopes)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
expiresWithSession(ctx: oidc.KoaContextWithOIDC, token): boolean {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
ttl: {
|
||||||
|
AccessToken: tokenExpiresIn1,
|
||||||
|
AuthorizationCode: 10 * 60,
|
||||||
|
DeviceCode: deviceCodeExpiresIn,
|
||||||
|
Grant: grantExpiresIn,
|
||||||
|
IdToken: tokenExpiresIn1,
|
||||||
|
RefreshToken: tokenExpiresIn2,
|
||||||
|
Session: 15 * 60,
|
||||||
|
Interaction: 15 * 60,
|
||||||
|
},
|
||||||
|
rotateRefreshToken: true,
|
||||||
|
routes: {
|
||||||
|
userinfo: '/userinfo'
|
||||||
|
},
|
||||||
|
clientDefaults: {
|
||||||
|
application_type: 'native',
|
||||||
|
response_types: ['code', 'id_token', 'code id_token'],
|
||||||
|
grant_types: ['authorization_code', 'implicit', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code'],
|
||||||
|
token_endpoint_auth_method: "client_secret_post"
|
||||||
|
},
|
||||||
|
discovery: {
|
||||||
|
shared_client_id: sharedClientId?.length ? sharedClientId : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
provider.proxy = true;
|
||||||
|
|
||||||
|
provider.on('server_error', (ctx, error) => {
|
||||||
|
console.log(error);
|
||||||
|
console.log(error.stack);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
如果签发的 Access Token 是 JWT,oidc-provider 不会把 Access Token 写到数据库里
|
||||||
|
所以要手动把 Access Token 保存到 Laravel Passport 的数据表中
|
||||||
|
*/
|
||||||
|
provider.on('access_token.issued', async (token: oidc.AccessToken) => {
|
||||||
|
|
||||||
|
const date: Date = getDateWithTimezoneOffset();
|
||||||
|
const maxTokenCount = parseInt(await this.getBlessingOption("ygg_tokens_limit", "5"));
|
||||||
|
const tokenIssued: PassportAccessToken[] = await prisma.client.passportAccessToken.findMany({
|
||||||
|
where: {
|
||||||
|
client_id: parseInt(token.clientId!),
|
||||||
|
user_id: parseInt(token.accountId),
|
||||||
|
name: ACCESS_TOKEN_NAME,
|
||||||
|
revoked: false,
|
||||||
|
expires_at: {
|
||||||
|
gte: date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tokenIssued.length >= maxTokenCount) {
|
||||||
|
tokenIssued.slice(0, tokenIssued.length - maxTokenCount + 1).forEach(async (token) => {
|
||||||
|
await prisma.client.passportAccessToken.update({
|
||||||
|
where: {
|
||||||
|
id: token.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
revoked: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.client.passportAccessToken.create({
|
||||||
|
data: {
|
||||||
|
id: token.jti,
|
||||||
|
client_id: parseInt(token.clientId!),
|
||||||
|
user_id: parseInt(token.accountId),
|
||||||
|
name: ACCESS_TOKEN_NAME,
|
||||||
|
scopes: JSON.stringify(token.extra?.scopes),
|
||||||
|
revoked: false,
|
||||||
|
created_at: date,
|
||||||
|
expires_at: new Date(date.getTime() + token.expiration * 1000),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
provider.on('refresh_token.consumed', async (rotatedRefreshToken: oidc.RefreshToken) => {
|
||||||
|
const passportRefreshToken = await prisma.client.passportRefreshToken.findFirst({
|
||||||
|
where: {
|
||||||
|
id: rotatedRefreshToken.jti,
|
||||||
|
revoked: false,
|
||||||
|
expires_at: {
|
||||||
|
gte: getDateWithTimezoneOffset()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
access_token_id: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (passportRefreshToken) {
|
||||||
|
await prisma.client.passportAccessToken.update({
|
||||||
|
where: {
|
||||||
|
id: passportRefreshToken.access_token_id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
revoked: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.client.passportRefreshToken.update({
|
||||||
|
where: {
|
||||||
|
id: rotatedRefreshToken.jti
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
revoked: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
刷新 Access Token 时也没法吊销原先的 Access Token,因为 oidc-provider 根本不知道上次签发的 Access Token 是哪个
|
||||||
|
所以要把 Refresh Token 对应的 Access Token 存起来,在刷新 Access Token 时手动吊销
|
||||||
|
*/
|
||||||
|
provider.on('access_token.issued', this.saveRefreshTokenToPassport.bind(this));
|
||||||
|
provider.on('refresh_token.saved', this.saveRefreshTokenToPassport.bind(this));
|
||||||
|
|
||||||
|
this.siteUrl = siteUrl;
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAccount(ctx: oidc.KoaContextWithOIDC, id: string, token?: oidc.AuthorizationCode | oidc.AccessToken | oidc.DeviceCode | oidc.RefreshToken): Promise<oidc.Account | undefined> {
|
||||||
|
const grantId = ctx.oidc.result?.consent?.grantId ?? token?.grantId;
|
||||||
|
|
||||||
|
const authCode = await this.prisma.client.passportAuthCode.findFirst({
|
||||||
|
where: {
|
||||||
|
id: grantId,
|
||||||
|
user_id: parseInt(id),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authCode) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grant = ctx.oidc.entities.Grant ?? await ctx.oidc.provider.Grant.find(grantId!);
|
||||||
|
if (!grant) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.client.user.findFirst({
|
||||||
|
where: {
|
||||||
|
uid: Number(authCode.user_id),
|
||||||
|
verified: true,
|
||||||
|
permission: {
|
||||||
|
not: -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo: UserInfo = {
|
||||||
|
sub: id,
|
||||||
|
nickname: user.nickname,
|
||||||
|
email: user.email,
|
||||||
|
email_verified: Boolean(user.verified),
|
||||||
|
picture: `${this.siteUrl}/avatar/user/${user.uid}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const scopes: string[] | undefined = grant.resources?.[BS_RESOURCE_INDICATOR]?.split(' ');
|
||||||
|
|
||||||
|
if (scopes?.includes(YggCScopes.PROFILE_SELECT)) {
|
||||||
|
const codeIdToUUID: CodeIdToUUID | null = await this.prisma.client.codeIdToUUID.findFirst({
|
||||||
|
where: {
|
||||||
|
code_id: grantId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!codeIdToUUID) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const uuid = await this.prisma.client.uUID.findFirst({
|
||||||
|
where: {
|
||||||
|
uuid: codeIdToUUID.uuid
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
player: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uuid) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
userInfo.selectedProfile = {
|
||||||
|
id: uuid.uuid,
|
||||||
|
name: uuid.player!.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopes?.includes(YggCScopes.PROFILE_READ)) {
|
||||||
|
const players: Player[] = await this.prisma.client.player.findMany({
|
||||||
|
where: {
|
||||||
|
uid: user.uid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
userInfo.availableProfiles = await Promise.all(players.map(async (player) => {
|
||||||
|
const uuid: UUID | null = await this.prisma.client.uUID.findFirst({
|
||||||
|
where: {
|
||||||
|
pid: player.pid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return uuid ? { id: uuid.uuid, name: player.name } : null;
|
||||||
|
})).then(profiles => profiles.filter(profile => profile !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: userInfo.sub,
|
||||||
|
async claims(use: string, scope: string, claims: object, rejected: string[]) {
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
oidc-provider 的事件触发很奇怪
|
||||||
|
当通过请求授权签发 Access Token 时,会先触发 access_token.issued 事件,再触发 refresh_token.saved 事件
|
||||||
|
但在刷新 Access Token 时,会先触发 refresh_token.saved 事件,再触发 access_token.issued 事件
|
||||||
|
所以两个事件的监听器中都需要尝试 upsert,确保 Refresh Token 保存在 Laravel Passport 的数据表中
|
||||||
|
*/
|
||||||
|
async saveRefreshTokenToPassport() {
|
||||||
|
// @ts-ignore
|
||||||
|
const ctx: oidc.OIDCContext | undefined = oidc.Provider.ctx?.oidc;
|
||||||
|
|
||||||
|
if (ctx?.entities.AccessToken && ctx?.entities.RefreshToken) {
|
||||||
|
const accessToken = ctx.entities.AccessToken;
|
||||||
|
const refreshToken = ctx.entities.RefreshToken;
|
||||||
|
await this.prisma.client.passportRefreshToken.upsert({
|
||||||
|
where: {
|
||||||
|
id: refreshToken.jti
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: refreshToken.jti,
|
||||||
|
access_token_id: accessToken.jti,
|
||||||
|
revoked: false,
|
||||||
|
expires_at: new Date(getDateWithTimezoneOffset().getTime() + refreshToken.expiration * 1000),
|
||||||
|
},
|
||||||
|
update: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBlessingOption(name: string): Promise<string | null>;
|
||||||
|
async getBlessingOption(name: string, defaultValue: string): Promise<string>;
|
||||||
|
async getBlessingOption(name: string, defaultValue?: string): Promise<string | null> {
|
||||||
|
const option = await this.prisma.client.option.findFirst({
|
||||||
|
where: {
|
||||||
|
name: name
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
value: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!option) {
|
||||||
|
if (defaultValue !== undefined) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return option.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ESNext",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user