增加relay相关接口

This commit is contained in:
CounterFire2023 2023-10-18 19:24:57 +08:00
parent b6d58c4f5b
commit 9835e55675
19 changed files with 1200 additions and 15 deletions

View File

@ -18,10 +18,12 @@
"@fastify/helmet": "^10.0.1",
"@fastify/jwt": "^6.3.2",
"@fastify/view": "^7.4.1",
"@metamask/eth-sig-util": "^4.0.1",
"axios": "^1.1.3",
"crypto": "^1.0.1",
"dotenv": "^16.0.3",
"ejs": "^3.1.9",
"ethers": "^5.6.8",
"fast-rbac": "^2.0.1",
"fastify": "^4.8.1",
"fastify-plugin": "^4.2.1",
@ -31,6 +33,7 @@
"nanoid": "^3.1.23",
"node-schedule": "^2.1.1",
"rustwallet": "file:./rustwallet",
"siwe": "^2.1.4",
"tracer": "^1.1.6",
"verify-apple-id-token": "^3.0.0"
},

View File

@ -8,6 +8,7 @@ import config from 'config/config'
import { ConnectOptions } from 'mongoose'
import CodeTaskSchedule from 'schedule/codetask.schedule'
import { PriceSvr } from 'service/price.svr'
import NonceRecordSchedule from 'schedule/noncerecord.schedule'
const zReqParserPlugin = require('plugins/zReqParser')
@ -89,6 +90,7 @@ export class ApiServer {
initSchedules() {
new CodeTaskSchedule().scheduleAll()
new NonceRecordSchedule().scheduleAll()
new PriceSvr().scheduleAll()
}
@ -118,13 +120,7 @@ export class ApiServer {
})
this.server.setErrorHandler(function (error: FastifyError, request: FastifyRequest, reply: FastifyReply) {
let statusCode = (error && error.statusCode) || 100
if (statusCode >= 500) {
logger.error(error)
} else if (statusCode >= 400) {
logger.info(error)
} else {
logger.error(error)
}
logger.info(error)
reply.code(200).send({
errcode: statusCode,
errmsg: error ? error.message : 'unknown error',

View File

@ -1,6 +1,7 @@
import fastify = require('fastify')
export const ROLE_ANON = 'anon'
export const ROLE_SESSION = 'session'
class BaseController {
aotoRoute(req: fastify.FastifyRequest, res) {}
}

View File

@ -9,6 +9,7 @@ import { Wallet } from 'modules/Wallet'
import { IPlat } from 'plats/IPlat'
import { PlatApple } from 'plats/PlatApple'
import { PlatClient } from 'plats/PlatClient'
import { PlatExternalWallet } from 'plats/PlatExternalWallet'
import { PlatFacebook } from 'plats/PlatFacebook'
import { PlatGoogle } from 'plats/PlatGoogle'
import { PlatTikTok } from 'plats/PlatTikTok'
@ -20,6 +21,9 @@ const plats: Map<PlatEnum, IPlat> = new Map([
[PlatEnum.FACEBOOK, new PlatFacebook()],
[PlatEnum.TIKTOK, new PlatTikTok()],
[PlatEnum.CLIENT, new PlatClient()],
[PlatEnum.WC, new PlatExternalWallet()],
[PlatEnum.EXTERNAL_WALLET, new PlatExternalWallet()],
[PlatEnum.RELAY_WALLET, new PlatExternalWallet()],
])
// 如果客户端有传入account, 则说明该次登录是绑定账号
@ -108,6 +112,9 @@ class LoginController extends BaseController {
}
const user = await Account.insertOrUpdate({ plat: channel, openId }, data)
const { unionAccount, walletUser } = await parseBindAccount(account, channel, user)
if (plat.afterLogin) {
await plat.afterLogin(user);
}
const ztoken = await res.jwtSign({
id: walletUser.id,
uid: unionAccount?.id || '',

View File

@ -0,0 +1,86 @@
import BaseController, {ROLE_ANON, ROLE_SESSION} from 'common/base.controller'
import {ZError} from 'common/ZError'
import { role, router } from 'decorators/router'
import logger from 'logger/logger'
import {RelayRecord, RelayStatusEnum} from 'modules/RelayRecord'
import {RelaySession} from 'modules/RelaySession'
import {checkPersionalSign} from 'utils/ether.util'
class RelayController extends BaseController {
@role(ROLE_ANON)
@router('post /wallet/relay/prepare')
async prepareClient(req, res) {
let {msg, address, signature} = req.params;
if (!msg || !address || !signature) {
throw new ZError(10, 'params mismatch' )
}
// check signature
if (!checkPersionalSign(msg, address, signature)) {
throw new ZError(11, 'signature mismatch' )
}
let session = new RelaySession({ msg, address, signature })
await session.save()
const ztoken = await res.jwtSign({
sid: session.id,
})
return { token: ztoken }
}
@role(ROLE_SESSION)
@router('post /wallet/relay/putdata')
async uploadData(req, res) {
let {data, type, session_id} = req.params;
if (type == undefined || !data) {
throw new ZError(10, 'params mismatch' )
}
type = parseInt(type);
let record = new RelayRecord({ sid: session_id, type, data })
await record.save()
return {id: record.id}
}
@role(ROLE_SESSION)
@router('post /wallet/relay/updata')
async updateData(req, res) {
let {data, id} = req.params;
if (!id || !data) {
throw new ZError(10, 'params mismatch' )
}
let record = await RelayRecord.findById(id);
record.status = RelayStatusEnum.RESOLVED;
record.resp = data;
await record.save()
return {id: record.id}
}
@role(ROLE_SESSION)
@router('post /wallet/relay/getlast')
async fetchLast(req, res) {
let { session_id, type } = req.params;
if (type == undefined) {
throw new ZError(10, 'params mismatch' )
}
type = parseInt(type);
let record = await RelayRecord.findLastRecord(session_id, type);
if (!record) {
throw new ZError(11, 'record not found' )
}
return {id: record.id, data: record.data, status: record.status}
}
@role(ROLE_SESSION)
@router('post /wallet/relay/getdata')
async fetchData(req, res) {
let { id } = req.params;
if (!id ) {
throw new ZError(10, 'params mismatch' )
}
let record = await RelayRecord.findById(id);
if (!record) {
throw new ZError(11, 'record not found' )
}
return {id: record.id, data: record.data, resp: record.resp, status: record.status}
}
}

View File

@ -0,0 +1,45 @@
import BaseController, {ROLE_ANON} from 'common/base.controller'
import {ZError} from 'common/ZError'
import { role, router } from 'decorators/router'
import logger from 'logger/logger'
import {DEFAULT_EXPIRED, NonceRecord} from 'modules/NonceRecord'
import {SiweMessage} from 'siwe'
import { checkParamsNeeded } from 'utils/net.util'
const LOGIN_TIP = 'This signature is just to verify your identity'
class SignController extends BaseController {
@role(ROLE_ANON)
@router('get /wallet/third/nonce')
async walletNonce(req, res) {
let record = new NonceRecord({ expired: Date.now() + DEFAULT_EXPIRED })
await record.save()
return { nonce: record.id, tips: LOGIN_TIP }
}
@role(ROLE_ANON)
@router('post /wallet/third/login')
async walletVerify(req, res) {
const { signature, message } = req.params
checkParamsNeeded(signature, message)
if (!message.nonce) {
throw new ZError(11, 'Invalid nonce');
}
let record = await NonceRecord.findById(message.nonce)
if (!record || record.status !== 0) {
throw new ZError(12, 'nonce invalid')
}
if (record.expired < Date.now()) {
throw new ZError(13, 'nonce expired')
}
record.status = 1
await record.save()
const msgSign = new SiweMessage(message);
try {
await msgSign.verify({ signature, nonce: record.id });
} catch (e) {
throw new ZError(14, 'signature invalid')
}
return {}
}
}

View File

@ -8,4 +8,7 @@ export enum PlatEnum {
EMAIL = 6,
DISCORD = 7,
CLIENT = 10,
RELAY_WALLET = 11,
WC = 12,
EXTERNAL_WALLET = 13,
}

View File

@ -0,0 +1,4 @@
export enum RelayTypeEnum {
TO_WALLET = 0,
FROM_WALLET = 1
}

View File

@ -27,11 +27,11 @@ export function verifyPass(userpassword: string, passwordDb: string, salt: strin
return passwordData.passwordHash === passwordDb
}
interface AccountClass extends Base, TimeStamps {}
export interface AccountClass extends Base, TimeStamps {}
@dbconn()
@index({ plat: 1, openId: 1 }, { unique: true })
@modelOptions({ schemaOptions: { collection: 'account', timestamps: true }, options: { allowMixed: Severity.ALLOW } })
class AccountClass extends BaseModule {
export class AccountClass extends BaseModule {
@prop({ enum: PlatEnum, default: PlatEnum.GOOGLE })
public plat!: PlatEnum
@prop({ required: true })

View File

@ -0,0 +1,21 @@
import { getModelForClass, modelOptions, prop } from '@typegoose/typegoose'
import { dbconn } from 'decorators/dbconn'
import { BaseModule } from './Base'
export const DEFAULT_EXPIRED = 1000 * 60 * 5
@dbconn()
@modelOptions({ schemaOptions: { collection: 'nonce_record', timestamps: true } })
class NonceRecordClass extends BaseModule {
@prop({ required: true, default: 0 })
public status: number
@prop()
public expired: number
public static async removeExpired() {
await NonceRecord.deleteMany({ expired: { $lt: Date.now() } })
}
}
export const NonceRecord = getModelForClass(NonceRecordClass, { existingConnection: NonceRecordClass['db'] })

View File

@ -0,0 +1,46 @@
import { getModelForClass, index, modelOptions, mongoose, prop, Severity } from '@typegoose/typegoose'
import { dbconn } from 'decorators/dbconn'
import {RelayTypeEnum} from 'enums/RelayTypeEnum'
import { BaseModule } from './Base'
export const DEFAULT_EXPIRED = 1000 * 60 * 5
export enum RelayStatusEnum {
PENDING = 0,
RESOLVED = 1,
FINISHED = 2,
FAILED = 9,
}
@dbconn()
@index({ sid: 1, type: 1 }, { unique: false })
@modelOptions({
schemaOptions: { collection: 'relay_data', timestamps: true },
options: { allowMixed: Severity.ALLOW },
})
class RelayRecordClass extends BaseModule {
@prop({ enum: RelayStatusEnum, default: RelayStatusEnum.PENDING })
public status: RelayStatusEnum
@prop({required: true})
public sid: string
@prop({ enum: RelayTypeEnum, default: RelayTypeEnum.TO_WALLET })
public type: RelayTypeEnum
@prop({ required: true, type: mongoose.Schema.Types.Mixed })
public data: any
@prop({type: mongoose.Schema.Types.Mixed })
public resp: any
public static async findLastRecord(sid: string, type: RelayTypeEnum) {
return RelayRecord.findOne({ sid, type }).sort({ _id: -1 })
}
public static async removeBySession(sid: string) {
await RelayRecord.deleteMany({ sid })
}
}
export const RelayRecord = getModelForClass(RelayRecordClass, { existingConnection: RelayRecordClass['db'] })

View File

@ -0,0 +1,30 @@
import { getModelForClass, modelOptions, prop } from '@typegoose/typegoose'
import { dbconn } from 'decorators/dbconn'
import { BaseModule } from './Base'
export const DEFAULT_EXPIRED = 1000 * 60 * 60
@dbconn()
@modelOptions({
schemaOptions: { collection: 'relay_session', timestamps: true },
})
class RelaySessionClass extends BaseModule {
@prop({ default: 0 })
public status: number
@prop()
public address: string
@prop({ default: Date.now() + DEFAULT_EXPIRED })
public expired: number
public async refreshExpired() {
this.expired = Date.now() + DEFAULT_EXPIRED;
}
public static async removeExpired() {
await RelaySession.deleteMany({ expired: { $lt: Date.now() } })
}
}
export const RelaySession = getModelForClass(RelaySessionClass, { existingConnection: RelaySessionClass['db'] })

View File

@ -1,3 +1,6 @@
import { DocumentType } from '@typegoose/typegoose'
import { AccountClass } from 'modules/Account'
export interface IPlat {
verifyToken(req: any): Promise<any>
afterLogin?(user: DocumentType<AccountClass>): Promise<any>
}

View File

@ -0,0 +1,48 @@
import { checkParamsNeeded } from 'utils/net.util'
import { IPlat } from './IPlat'
import { ZError } from 'common/ZError';
import { NonceRecord } from 'modules/NonceRecord';
import { SiweMessage } from 'siwe';
import { DocumentType } from '@typegoose/typegoose'
import { AccountClass } from 'modules/Account'
import { Wallet } from 'modules/Wallet';
export class PlatExternalWallet implements IPlat {
async verifyToken(req: any): Promise<any> {
// here code is signature
let { code, message } = req.params
checkParamsNeeded(code, message);
if (!message.nonce) {
throw new ZError(11, 'Invalid nonce');
}
let record = await NonceRecord.findById(message.nonce)
if (!record || record.status !== 0) {
throw new ZError(12, 'nonce invalid')
}
if (record.expired < Date.now()) {
throw new ZError(13, 'nonce expired')
}
record.status = 1
await record.save()
const msgSign = new SiweMessage(message);
try {
await msgSign.verify({ signature: code, nonce: record.id });
} catch (e) {
throw new ZError(14, 'signature invalid')
}
const openId = message.address
let data: any = {}
const { api_platform } = req.headers
if (api_platform) {
data.platform = api_platform
}
return { openId, data }
}
async afterLogin(user: DocumentType<AccountClass>) {
await Wallet.insertOrUpdate({ account: user.id }, { address: user.openId, nweRecord: false })
}
}

View File

@ -1,6 +1,8 @@
import {ROLE_ANON, ROLE_SESSION} from 'common/base.controller'
import { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import fastifyPlugin from 'fastify-plugin'
import { Account } from 'modules/Account'
import {RelaySession} from 'modules/RelaySession'
declare module 'fastify' {
interface FastifyRequest {
@ -28,7 +30,7 @@ const apiAuthPlugin: FastifyPluginAsync<ApiAuthOptions> = async function (fastif
})
// 只有路由配置的role为anon才不需要过滤
fastify.decorate('apiAuth', async function (request: FastifyRequest, reply: FastifyReply) {
if (!request.roles || request.roles.indexOf('anon') == -1) {
if (!(!!request.roles && (request.roles.indexOf(ROLE_ANON) > -1 || request.roles.indexOf(ROLE_SESSION) > -1))) {
try {
if (!request.token) {
return reply.send({ errcode: 11, errmsg: 'need login' })
@ -46,6 +48,27 @@ const apiAuthPlugin: FastifyPluginAsync<ApiAuthOptions> = async function (fastif
} catch (err) {
return reply.send({ errcode: 401, errmsg: 'need auth' })
}
} else if ( !!request.roles && request.roles.indexOf(ROLE_SESSION) != -1) {
try{
if (!request.token) {
return reply.send({ errcode: 11, errmsg: 'need login' })
}
//@ts-ignore
const data = this.jwt.verify(request.token)
if (!data || !data.sid) {
return reply.send({ errcode: 10, errmsg: 'need login' })
}
let session = await RelaySession.findById(data.sid)
if (!session) {
return reply.send({ errcode: 10, errmsg: 'need login' })
}
session.refreshExpired();
await session.save();
request.params['session_id'] = session.id;
request.params['session_address'] = session.address;
} catch (err) {
return reply.send({ errcode: 401, errmsg: 'need auth' })
}
}
})
}

View File

@ -0,0 +1,25 @@
import { singleton } from 'decorators/singleton'
import {NonceRecord} from 'modules/NonceRecord'
import * as schedule from 'node-schedule'
/**
*
*/
@singleton
export default class NonceRecordSchedule {
async parseAllFinishedRecord() {
await NonceRecord.deleteMany({status: 1});
}
async parseAllExpiredRecord() {
let now = Date.now()
await NonceRecord.deleteMany({expired: {$lt: now}})
}
scheduleAll() {
schedule.scheduleJob('*/1 * * * *', async () => {
await this.parseAllFinishedRecord()
})
schedule.scheduleJob('*/5 * * * *', async () => {
await this.parseAllExpiredRecord()
})
}
}

27
src/utils/ether.util.ts Normal file
View File

@ -0,0 +1,27 @@
import {bytesToHex} from '@noble/hashes/utils'
import {keccak_256} from '@noble/hashes/sha3'
import {recoverPersonalSignature} from '@metamask/eth-sig-util';
export function toEIP55(address: string) {
const lowerAddress = `${address}`.toLowerCase().replace('0x', '');
var hash = bytesToHex(keccak_256(lowerAddress))
var ret = '0x';
for (var i = 0; i < lowerAddress.length; i++) {
if (parseInt(hash[i], 16) >= 8) {
ret += lowerAddress[i].toUpperCase();
}
else {
ret += lowerAddress[i];
}
}
return ret;
}
export function checkPersionalSign(message: string, address: string, signature: string ) {
if (!signature.startsWith('0x')) {
signature = '0x' + signature
}
const recovered = recoverPersonalSignature({data: message, signature})
return recovered === address
}

View File

@ -1,3 +1,5 @@
import { ZError } from "common/ZError"
const TIMEOUT_ERROR = new Error('timeout')
const hexRe = /^[0-9A-Fa-f]+$/gu
@ -184,3 +186,9 @@ export function keyValToObject(
}
return result;
}
export const checkParamsNeeded = (...args) => {
args.forEach((arg) => {if (!arg) {
throw new ZError(10, 'params mismatch');
}});
}

819
yarn.lock

File diff suppressed because it is too large Load Diff