添加 S3支持 完成皮肤上传和安全验证
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Qumolama.d 2022-07-02 20:35:20 +08:00
parent e143d3aa6c
commit 32f7577a07
Signed by: Lama3L9R
GPG Key ID: 1762AFC05157CE18
13 changed files with 2281 additions and 1178 deletions

5
.gitignore vendored
View File

@ -3,5 +3,6 @@ node_modules
production
**/*.key
**/*.pem
# My own launch script which should NEVER upload since there is secrets!
launch.ps1
# You should NEVER EVER UPLOAD THIS FILE
launch.ps1

View File

@ -1,4 +1,4 @@
<div aligen='center'>
<div align='center'>
<img src='logo.png'/>
<h1>LSP Yggdrasil</h1>
</div>
@ -10,7 +10,7 @@
老色批世界树 —— 一个高性能麻将、奥苏力不-印寨克托接口的实现。使用fastify来把处理速度加速到老色批的速度
具体有多快呢?登录处理从数据包发出到接收到服务端响应仅需要 __***6ms***__根据机器不同可能会有浮动以实际情况为准
具体有多快呢?登录处理从数据包发出到接收到服务端响应仅需要 __***5ms***__根据机器不同可能会有浮动以实际情况为准
---
@ -23,18 +23,14 @@
+ [x] /sessionserver
+ [x] 测试
+ [x] /api
- [ ] 进阶 API
- [ ] 皮肤上传和安全检查
- [x] 进阶 API
- [x] 皮肤上传和安全检查
+ [x] 皮肤数据的RSA签名
+ [ ] 皮肤上传
+ [x] 皮肤上传
+ [x] 安全检查
- [ ] 兼容S3后端
- [x] 兼容S3后端
- [x] 服务器状态接口
- [x] authlib-injector 元数据接口
- [ ] TGbot前端
+ [x] 注册
+ [ ] 用户查询、改密等
+ [ ] 管理员指令
#### Release 1.0
- [ ] 单元测试
@ -44,11 +40,7 @@
- [ ] /api
- [ ] Advanced API
+ [ ] Utils
#### 未来的版本:
- [ ] 完整 web 管理
---
## 使用方法
@ -62,6 +54,11 @@
4. 使用 `$ node build.js` 创建运行时构建
5. 使用 `$ node path/to/lsp-yggdrasil.full.cjs` 起飞
###### 若需要开发者环境您还需要额外几步:
1. 执行 `$ yarn dev:mklaunch` 来生成启动脚本
2. 使用 `$ yarn dev` 启动开发模式服务器
> 注:对于开发者的首次运行,您可能需要创建一个测试账户,添加环境参数:`DEVEL_FIRST_RUN=1` 即可创建一个测试玩家
> 测试玩家的账户为: `i@lama.icu` 密码为 `123456` 您可以到 `index.js` 中自由修改
@ -70,7 +67,10 @@
- Q支持 `https` 嘛?
- A不支持请使用反代来使用`https`,或者您也可以修改 `index.js` 中初始化代码。
- Q: 支持 `IPv6` 嘛?
- A: 完美支持 `IPv6`
## 内置的 yarn 指令
+ `dev` —— 启动开发环境服务器
+ `test` —— 单元测试
+ `test` —— 单元测试

View File

@ -13,18 +13,20 @@
"shelljs.exec": "^1.1.8"
},
"dependencies": {
"@fastify/swagger": "^6.0.1",
"aws-sdk": "^2.1140.0",
"@aws-sdk/client-s3": "^3.121.0",
"@aws-sdk/s3-request-presigner": "^3.121.0",
"@fastify/swagger": "^7.4.1",
"axios": "^0.27.2",
"fastify": "^3.29.0",
"fastify": "^4.2.0",
"hex-to-uuid": "^1.1.1",
"mongoose": "^6.3.1",
"pino": "^8.1.0",
"pino-pretty": "^7.6.1",
"pngjs": "^6.0.0",
"telegraf": "^4.8.2"
},
"scripts": {
"dev": "nodemon --watch src/index.js",
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules UNIT_TEST=1 NODE_NO_WARNINGS=1 jest"
"dev": "node launch-development.js",
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules UNIT_TEST=1 NODE_NO_WARNINGS=1 jest",
}
}

View File

@ -21,6 +21,12 @@ export const config = {
endpoint: "",
bucket: "",
key: "",
secret: "",
extra: { // these configs will pass to s3client directly
region: 'us-ashburn-02',
forcePathStyle: true,
signatureVersion: 'v4',
}
},
signing: { // 签名材质信息使用

View File

@ -5,12 +5,8 @@ export async function headerValidation(req, rep) {
}
if(Object.keys(req.headers).some(key => {
req.log.info(key.toLowerCase() === "content-type")
req.log.info(req.headers[key].toLowerCase())
return key.toLowerCase() === "content-type" && req.headers[key].toLowerCase().indexOf("application/json") === -1
})) {
req.log.info(JSON.stringify(req.headers))
return rep.code(400).send({
error: "IllegalArgumentException",
errorMessage: "请求内容不正确",
@ -19,10 +15,19 @@ export async function headerValidation(req, rep) {
}
}
export async function handleError(err, req, rep) {
return rep.code(500).send({
error: "InternalServerError",
errorMessage: "服务器内部错误",
cause: err.message
})
}
export async function handleError(err, req, rep) {
req.log.info(err)
if(!(/(authserver)|(sessionserver)|(api)/g).test(req.url)) {
return rep.code(500).send({
error: "InternalServerError",
errorMessage: "服务器内部错误",
cause: err.message
})
} else {
return await rep.code(500).send({
err: 0.000000,
msg: "服务器内部错误",
extra: err
})
}
}

View File

@ -12,7 +12,9 @@ import { Scenes, session, Telegraf } from 'telegraf'
import { allScenes, registerAllPlayerCommands } from './telegram/player-commands.js'
import { Player } from './models/player.js'
import fastifySwagger from '@fastify/swagger'
import S3 from 'aws-sdk/clients/s3.js'
import { S3Client } from '@aws-sdk/client-s3'
import multipart from '@fastify/multipart'
import pino from 'pino'
String.prototype._split = String.prototype.split
@ -43,31 +45,43 @@ for(let i = 0; i < process.argv.length; i++) {
const curr = process.argv[i]
if(curr.startsWith('--')) {
switch(curr.substring(2)){
case 'override':
case 'override': {
const [next, value] = process.argv[i + 1].split(":", 2)
if(!next || next.startsWith('--')) {
continue
} else {
if(next || !next.startsWith('--')) {
eval(`config.${next} = '${value}'`)
}
continue
}
default: {
continue
}
}
}
}
export const serverLogger = pino({
transport: {
target: 'pino-pretty'
}
})
export const server = fastify({
logger: {
prettyPrint: true,
// level: 'error'
}
logger: serverLogger
})
export const telegraf = new Telegraf(config.telegram.token)
export const s3Instance = new S3({
accessKeyId: config.storage.key,
export const s3Instance = new S3Client({
credentials: {
accessKeyId: config.storage.key,
secretAccessKey: config.storage.secret,
},
endpoint: config.storage.endpoint,
...config.storage.extra
})
export const setup = async () => {
server.log.info("老色批世界树 > 初始化中...")
@ -76,12 +90,22 @@ export const setup = async () => {
const privateKey = readFileSync(config.signing.private).toString()
server.decorate('keys', { publicKey, privateKey })
s3Instance.putObject()
config.custom.preHooks(server)
server.addHook('preHandler', Hooks.headerValidation)
server.register(fastifySwagger, {
server.setErrorHandler(Hooks.handleError)
server.addContentTypeParser('image/png', (_, payload, done) => {
done(null, payload)
})
server.register(multipart, {
limit: {
fileSize: 5 * 1024 * 1024 // 5MB/40Mb
}
})
server.register(fastifySwagger, {
routePrefix: '/docs',
swagger: {
title: "lsp-yggdrasil 接口文档",
@ -97,7 +121,7 @@ export const setup = async () => {
})
config.custom.preRouting(server)
// Authserver routings
server.route(AuthenticateRoutings.authenticate)
server.route(AuthenticateRoutings.refresh)
@ -116,6 +140,8 @@ export const setup = async () => {
server.route(WebAPIRoutings.login)
server.route(WebAPIRoutings.register)
server.route(WebAPIRoutings.textures)
server.route(WebAPIRoutings.uploadTexture)
config.custom.postRouting(server)
@ -143,6 +169,8 @@ export const setup = async () => {
telegraf.use(session())
telegraf.use(stage.middleware())
// server.log.info("老色批世界树 > 初始化中...")
registerAllPlayerCommands()
}
@ -150,9 +178,9 @@ const launch = async () => {
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
telegraf.launch()
await telegraf.launch()
await server.listen(config.server.port, config.server.url)
await server.listen({ port: config.server.port, url: config.server.url })
server.log.info("老色批世界树 > 基于 fastify 的高性能 HTTP 服务器已启动")
}
@ -188,4 +216,4 @@ export const shutdown = async () => {
await setup()
await launch()
}
})()
})()

View File

@ -1,7 +1,10 @@
import mongoose from 'mongoose'
import { uuidToNoSymboUUID } from '../generator.js'
import { ImageSecurity } from '../secure.js'
import { server } from '../index.js'
import { s3Instance } from '../index.js'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { GetObjectCommand } from '@aws-sdk/client-s3'
import { config } from '../config.js'
export const Player = mongoose.model("Player", new mongoose.Schema({
username: String, // 有符号 UUID
@ -72,7 +75,7 @@ export const PlayerAccountSerializationSchema = {
}
}
export function getPlayerSerialization(player) {
export async function getPlayerSerialization(player) {
const textures = {
timestamp: 0,
profileId: uuidToNoSymboUUID(player.uuid),
@ -82,30 +85,23 @@ export function getPlayerSerialization(player) {
if(player.textures.skin && player.textures.skin != 0) { // Must be '!=' if this change to '!==' will never works
textures.textures.SKIN = {
url: player.textures.skin
url: await getSignedUrl(s3Instance, new GetObjectCommand({
Bucket: config.storage.bucket,
Key: player.textures.skin
}), { expiresIn: 3 * 24 * 60 * 60 }) // 3 days
}
}
if(player.textures.cape && player.textures.cape != 0) { // Must be '!=' if this change to '!==' will never works
textures.textures.CAPE = {
url: player.textures.cape,
url: await getSignedUrl(s3Instance, new GetObjectCommand({
Bucket: config.storage.bucket,
Key: player.textures.cape
}), { expiresIn: 3 * 24 * 60 * 60 }) // 3 days
}
}
const val = Buffer.from(JSON.stringify(textures)).toString('base64')
server.log.info({
id: uuidToNoSymboUUID(player.uuid),
name: player.username,
properties: [
{
name: "texturs",
value: val,
signature: ImageSecurity.sign(val),
}
]
})
return {
id: uuidToNoSymboUUID(player.uuid),
name: player.username,

View File

@ -107,7 +107,7 @@ export const authenticate = {
]
}
const profile = PlayerModel.getPlayerSerialization(player)
const profile = await PlayerModel.getPlayerSerialization(player)
await Token.deleteMany({ player: player.uuid }).exec()
@ -145,16 +145,22 @@ export const refresh = {
"type": "string"
},
"clientToken": {
"type": "string",
"optional": true
anyOf: [
{ type: 'string' },
{ type: 'null' }
],
},
"requestUser": {
"type": "boolean",
"optional": true
anyOf: [
{ type: 'boolean' },
{ type: 'null' }
]
},
"selectedProfile": {
"optional": true,
...PlayerModel.PlayerSeriliazationSchema
anyOf: [
{ ...PlayerModel.PlayerSeriliazationSchema },
{ type: 'null' }
]
}
}
},
@ -247,7 +253,7 @@ export const refresh = {
}
if(selectedProfile) {
response.selectedProfile = PlayerModel.getPlayerSerialization(player)
response.selectedProfile = await PlayerModel.getPlayerSerialization(player)
}
}
@ -269,8 +275,10 @@ export const validate = {
"type": "string"
},
"clientToken": {
"type": "string",
"optional": true
anyOf: [
{ "type": "string" },
{ type: 'null' },
]
}
}
},
@ -329,9 +337,11 @@ export const invalidate = {
"type": "string"
},
"clientToken": {
"type": "string",
"optional": true
}
anyOf: [
{ "type": "string" },
{ type: 'null' },
]
}
}
},
response: {
@ -379,8 +389,10 @@ export const signout = {
"type": "string"
},
"password": {
"type": "string",
"optional": true
anyOf: [
{ "type": "string" },
{ type: 'null' }
]
}
}
},
@ -409,4 +421,4 @@ export const signout = {
rep.code(204).send()
}
}
}

View File

@ -136,7 +136,7 @@ export const hasJoined = {
})
}
await rep.code(200).send(getPlayerSerialization(player))
await rep.code(200).send(await getPlayerSerialization(player))
}
}
@ -152,8 +152,10 @@ export const profile = {
"type": "string"
},
unsigned: {
"type": "boolean",
"optional": true
anyOf: [
{ "type": "boolean" },
{ type: 'null' }
]
},
},
response: {
@ -171,7 +173,7 @@ export const profile = {
return await rep.code(204).send()
}
await rep.code(200).send(getPlayerSerialization(player))
await rep.code(200).send(await getPlayerSerialization(player))
}
}
}

View File

@ -1,8 +1,11 @@
import { getOverrideHandler, getOverridePreHandler } from "../config.js"
import { config, getOverrideHandler, getOverridePreHandler } from "../config.js"
import { Player } from "../models/player.js"
import { createHash } from "crypto"
import { generateToken, uuid } from "../generator.js"
import { Token } from "../models/token.js"
import { s3Instance } from "../index.js"
import { ImageSecurity } from "../secure.js"
import { PutObjectCommand } from "@aws-sdk/client-s3"
const BASE_RESPONSE = {
err: {
@ -20,12 +23,36 @@ const BASE_RESPONSE = {
const identifiers = new Map()
async function identifierValidator(req, rep) {
const identifier = req.headers['X-LSP-Idenitifier']
const identifier = req.headers['x-lsp-identifier']
if(!identifier) {
return await rep.code(401).send({
return await rep.code(400).send({
err: 1.143688,
msg: "请求格式不正确"
})
}
if(!identifiers.has(identifier)) {
return await rep.code(401).send({
err: 0.456914,
msg: "用户不存在"
})
}
const {t, uuid} = identifiers.get(identifier)
if(t < Date.now()) {
return await rep.code(401).send({
err: 1.143688,
msg: "令牌超时"
})
}
identifiers.set(identifier, {
t: Date.now() + 1000 * 60 * 60,
uuid
})
req.player = await Player.findOne({ uuid })
req.t = t
}
export const login = {
@ -40,18 +67,15 @@ export const login = {
properties: {
username: {
type: 'string',
description: '用户名',
example: 'test'
description: '用户名'
},
password: {
type: 'string',
description: '密码',
example: '123456'
description: '密码'
},
createToken: {
type: 'boolean',
description: '是否创建一个 accessToken',
example: false
description: '是否创建一个 accessToken'
}
}
},
@ -63,52 +87,36 @@ export const login = {
extra: {
type: 'object',
description: '额外信息',
example: {
identifier: '<token>',
textures: {
skin: '<url>',
cape: '<url>'
},
username: '<username>',
uuid: '<uuid>'
},
properties: {
identifier: {
type: 'string',
description: 'identifier后面请求必须带 X-LSP-Idenitifier: <token> 请求头',
example: '<token>'
description: 'identifier后面请求必须带 X-LSP-Idenitifier: <token> 请求头'
},
textures: {
type: 'object',
description: '用户皮肤和披风',
example: {
skin: '<url>',
cape: '<url>'
},
properties: {
skin: {
type: 'string',
description: '用户皮肤',
example: '<url>',
optional: true,
anyOf: [
{ type: 'string', description: '皮肤' },
{ type: 'null' }
]
},
cape: {
type: 'string',
description: '用户披风',
example: '<url>',
optional: true,
anyOf: [
{ type: 'string', description: '披风' },
{ type: 'null' }
]
}
},
},
username: {
type: 'string',
description: '用户名',
example: '<username>'
description: '用户名'
},
uuid: {
type: 'string',
description: '用户唯一标识',
example: '<uuid>'
description: '用户唯一标识'
}
}
}
@ -119,13 +127,11 @@ export const login = {
properties: {
err: {
type: 'number',
description: '错误类型',
example: 1.048596
description: '错误类型'
},
msg: {
type: 'string',
description: '错误内容,展示给用户看的',
example: "您输入的密码似乎是用户 lama 的,请确保用户名没有打错!"
description: '错误内容,展示给用户看的'
},
}
@ -143,7 +149,7 @@ export const login = {
});
}
if(!user.permissions.some((it) => { s
if(!user.permissions.some((it) => {
return it.node === 'login' && it.allowed && (it.duration === 0 || it.startDate + it.duration > Date.now())
})) {
return await rep.code(401).send({
@ -195,42 +201,42 @@ export const register = {
properties: {
username: {
type: 'string',
description: '用户名',
example: 'test'
description: '用户名'
},
password: {
type: 'string',
description: '密码',
example: '123456'
description: '密码'
},
email: {
type: 'string',
description: '邮箱',
example: ''
description: '邮箱'
},
telegramId: {
type: 'string',
description: 'telegramId',
example: ''
description: 'telegramId'
},
textureMigrations: {
type: 'object',
description: '纹理迁移',
optional: true,
properties: {
skin: {
type: 'string',
description: '皮肤',
optional: true,
example: 'https://assets.lama.icu/textures/skin/steve.png'
anyOf: [
{
type: 'object',
description: '纹理迁移',
properties: {
skin: {
anyOf: [
{ type: 'string', description: '皮肤' },
{ type: 'null' }
]
},
cape: {
anyOf: [
{ type: 'string', description: '披风' },
{ type: 'null' }
]
}
}
},
cape: {
type: 'string',
description: '披风',
optional: true,
example: 'https://assets.lama.icu/textures/cape/steve.png'
}
}
{ type: 'null' }
]
}
}
},
@ -242,42 +248,42 @@ export const register = {
extra: {
username: {
type: 'string',
description: '用户名',
example: 'test'
description: '用户名'
},
password: {
type: 'string',
description: '密码',
example: '123456'
description: '密码'
},
email: {
type: 'string',
description: '邮箱',
example: ''
description: '邮箱'
},
telegramId: {
type: 'string',
description: 'telegramId',
example: ''
description: 'telegramId'
},
textureMigrations: {
type: 'object',
description: '纹理迁移',
optional: true,
properties: {
skin: {
type: 'string',
description: '皮肤',
optional: true,
example: 'https://assets.lama.icu/textures/skin/steve.png'
anyOf: [
{
type: 'object',
description: '纹理迁移',
properties: {
skin: {
anyOf: [
{ type: 'string', description: '皮肤' },
{ type: 'null' }
]
},
cape: {
anyOf: [
{ type: 'string', description: '披风' },
{ type: 'null' }
]
}
}
},
cape: {
type: 'string',
description: '披风',
optional: true,
example: 'https://assets.lama.icu/textures/cape/steve.png'
}
}
{ type: 'null' }
]
}
}
}
@ -336,4 +342,126 @@ export const register = {
msg: '',
})
}
}
export const textures = {
method: 'GET',
url: '/api/textures',
schema: {
summary: "获取材质",
description: `获取材质200正确返回 <<< Copilot自己补全的`,
tags: [ 'webapi' ],
response: {
200: {
type: 'object',
properties: {
...BASE_RESPONSE,
extra: {
textures: {
type: 'object',
description: '纹理',
properties: {
skin: {
type: 'string',
description: '皮肤',
example: 'https://assets.lama.icu/textures/skin/steve.png'
},
cape: {
type: 'string',
description: '披风',
example: 'https://assets.lama.icu/textures/cape/steve.png'
}
}
}
}
}
}
}
},
preHandler: getOverridePreHandler('/api/textures') ?? identifierValidator,
handler: getOverrideHandler('/api/textures') ?? async function(req, rep) {
return await rep.code(200).send({
err: 1.048596,
msg: '',
extra: {
textures: {
skin: req.user.textures.skin,
cape: req.user.textures.cape
}
}
})
}
}
export const uploadTexture = {
method: 'PUT',
url: '/api/textures/:type',
schema: {
summary: "上传材质",
description: `上传材质200正确返回 <<< Copilot自己补全的`,
tags: [ 'webapi' ],
response: {
200: {
type: "object",
properties: {
...BASE_RESPONSE
}
},
400: {
type: 'object',
properties: {
...BASE_RESPONSE
}
}
}
},
preHandler: getOverridePreHandler('/api/textures') ?? identifierValidator,
handler: getOverrideHandler('/api/textures') ?? async function(req, rep) {
const { type } = req.params
if(type !== 'skin' && type !== 'cape') {
rep.code(400).send({
err: 1.143688,
msg: "请求格式不正确"
})
}
const [hash, texture] = await ImageSecurity.createImageHash(req.body, type === 'cape')
req.log.info(`Hash: ${hash}`)
const buf = await new Promise((resolve, reject) => {
const buff = []
texture.on("data", (d) => buff.push(d))
texture.on("end", () => resolve(Buffer.concat(buff)))
texture.on("error", (e) => reject(e))
})
await s3Instance.send(new PutObjectCommand({
Bucket: config.storage.bucket,
Key: `${type}/${hash}`,
Body: buf,
ACL: 'public-read'
}))
req.log.info(`玩家: ${req.player.username} 上传皮肤,哈希为: ${hash} 成功上传到S3 Key: ${type}/${hash}`)
const update = { }
update[type] = `${type}/${hash}`
req.log.info(JSON.stringify({
'$set': {
textures: update
}
}))
await Player.updateOne({ username: req.player.username }, {
'$set': {
textures: update
}
})
await rep.code(200).send({
err: 1.048596,
msg: ""
})
}
}

View File

@ -1,24 +1,23 @@
import { PNG } from 'pngjs'
import fs from 'fs'
import { createHash, createPrivateKey, createSign } from 'crypto'
import { config } from './config.js'
import { server } from './index.js'
export const ImageSecurity = {
createImageHash: async (path, isCape) => {
createImageHash: async (stream, isCape) => {
const png = await new Promise((resolve, reject) => {
fs.createReadStream(path).pipe(new PNG()).on('metadata', function(metadata) {
stream.pipe(new PNG()).on('metadata', function(metadata) {
if(metadata.width * metadata.height > 40960) {
reject('Image size exceeds 40960 pixels')
}
}).on('parsed', function(buff) {
}).on('parsed', function(_) {
resolve(this)
})
})
if (isCape) {
if(png.width % 64 === 0 && png.height % 32 === 0) {
return createHashInternal(png)
return [createHashInternal(png), stream]
} else if (png.width % 22 === 0 && png.height % 17 === 0) {
const newPNG = new PNG({
width: Math.ceil(png.width / 22),
@ -44,8 +43,8 @@ export const ImageSecurity = {
newPNG.data[idx + 3] = 0
}
}
return createHashInternal(newPNG)
return [createHashInternal(newPNG), newPNG.pack()]
}
} else {
if(png.width % 64 === 0 && png.height % 32 === 0) {
@ -64,7 +63,7 @@ export const ImageSecurity = {
}
}
return createHashInternal(png)
return [createHashInternal(png), newPNG.pack()]
}
}
@ -87,4 +86,4 @@ const createHashInternal = (png) => {
}
}
return hash.digest('hex')
}
}

View File

@ -1,2 +0,0 @@
import axios from "axios"

2904
yarn.lock

File diff suppressed because it is too large Load Diff