增加邮件注册登录相关接口

This commit is contained in:
zhl 2023-04-04 18:50:57 +08:00
parent 6e9fe44134
commit aa1177b802
12 changed files with 415 additions and 17 deletions

View File

@ -9,7 +9,7 @@ GOOGLE_OAUTH_CLIENT2="53206975661-ih3r0ubph3rqejdq97b029difbrk2bqj.apps.googleus
GOOGLE_OAUTH_CLIENT_IOS="53206975661-qan0rnefniegjv53ohild375pv0p7ekd.apps.googleusercontent.com"
DB_MAIN=mongodb://localhost/wallet-development
EMAIL_VERIFY_URL="http://127.0.0.1:3007"
EMAIL_VERIFY_URL="https://wallet.cebggame.com"
ALCHEMY_APPID="f83Is2y7L425rxl8"
ALCHEMY_APP_SECRET="4Yn8RkxDXN71Q3p0"

View File

@ -29,11 +29,13 @@
"mongoose": "^6.6.5",
"mongoose-findorcreate": "^3.0.0",
"nanoid": "^3.1.23",
"node-schedule": "^2.1.1",
"tracer": "^1.1.6",
"verify-apple-id-token": "^3.0.0"
},
"devDependencies": {
"@typegoose/typegoose": "^9.12.1",
"@types/node-schedule": "^2.1.0",
"@types/dotenv": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^5.40.1",
"@typescript-eslint/parser": "^5.40.1",

View File

@ -6,6 +6,7 @@ import { mongoose } from '@typegoose/typegoose'
import logger from 'logger/logger'
import config from 'config/config'
import { ConnectOptions } from 'mongoose'
import CodeTaskSchedule from 'schedule/codetask.schedule'
const zReqParserPlugin = require('plugins/zReqParser')
@ -30,11 +31,11 @@ export class ApiServer {
this.server.register(zReqParserPlugin)
this.server.register(helmet, { hidePoweredBy: false })
this.server.register(zTokenParserPlugin)
this.server.register(require("@fastify/view"), {
this.server.register(require('@fastify/view'), {
engine: {
ejs: require("ejs"),
ejs: require('ejs'),
},
});
})
this.server.register(apiAuthPlugin, {
secret: config.api.token_secret,
@ -83,6 +84,11 @@ export class ApiServer {
return require(join(controllers, file))
})
}
initSchedules() {
new CodeTaskSchedule().scheduleAll()
}
async connectDB() {
const options: ConnectOptions = {
minPoolSize: 5,
@ -152,7 +158,7 @@ export class ApiServer {
self.registerRouter()
self.setErrHandler()
self.setFormatSend()
// new Schedule().start();
self.initSchedules()
this.server.listen({ port: config.api.port, host: config.api.host }, (err: any, address: any) => {
if (err) {
logger.log(err)

View File

@ -36,8 +36,8 @@ class FacebookController extends BaseController {
user.accessToken = code
user.accessTokenExpire = result.data['expires_at']
user.scope = data['scopes']
user.nickname = infoRes['name']
user.email = infoRes['email']
if (infoRes['name']) user.nickname = infoRes['name']
if (infoRes['email']) user.email = infoRes['email']
let account = await Account.insertOrUpdate({ plat: PlatEnum.FACEBOOK, openId }, user)
const ztoken = await res.jwtSign({ id: account.id })
return { token: ztoken }

View File

@ -0,0 +1,163 @@
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 { Account, PlatEnum } from 'modules/Account'
import { CodeRecord, CodeStatus, CodeType, DEFAULT_CODE, DEFAULT_EXPIRE_TIME } from 'modules/CodeRecord'
import {
DEFAULT_REGIST_HTML,
DEFAULT_REGIST_SUBJECT,
DEFAULT_RESET_HTML,
DEFAULT_RESET_SUBJECT,
EmailSvr,
} from 'service/email.svr'
import { uuid } from 'utils/security.util'
const PASSWORD_SALT = '^6AssF(n,/]2>Iv<Nk|mEdFR'
class MailController extends BaseController {
/**
* ,
*/
@role(ROLE_ANON)
@router('post /wallet/login/email')
async loginWithEmail(req, res) {
let { email, pass } = req.params
if (!email || !pass) {
throw new ZError(10, 'params mismatch')
}
// check Account exists
let record = await Account.findByEmail(email)
if (!record) {
throw new ZError(11, 'account not exists')
}
if (record.locked) {
throw new ZError(12, 'account locked')
}
if (!record.verifyPassword(pass)) {
throw new ZError(13, 'password error')
}
const token = await res.jwtSign({ id: record.id })
return { token: token }
}
/**
* email账号
*/
@role(ROLE_ANON)
@router('post /email/regist')
async registMailAccount(req, res) {
let { email, code, password } = req.params
if (!email || !code || !password) {
throw new ZError(10, 'params mismatch')
}
let account = await Account.findByEmail(email)
if (account) {
throw new ZError(11, 'account exists')
}
let record = await CodeRecord.findByEmail(email, CodeType.REGIST)
if (!record) {
throw new ZError(12, 'code not exists')
}
if (record.status !== CodeStatus.PENDING) {
throw new ZError(13, 'code expired')
}
if (record.code !== code) {
throw new ZError(14, 'code error')
}
account = new Account({ plat: PlatEnum.EMAIL, email, openId: uuid() })
account.updatePassword(password)
account.emailReal = email
account.emailVerified = true
await account.save()
record.status = CodeStatus.SUCCESS
await record.save()
const ztoken = await res.jwtSign({ id: account.id })
return { token: ztoken }
}
/**
* email重置密码
*/
@role(ROLE_ANON)
@router('post /email/reset_password')
async resetMailPassword(req, res) {
let { email, code, password } = req.params
if (!email || !code || !password) {
throw new ZError(10, 'params mismatch')
}
let account = await Account.findByEmail(email)
if (!account) {
throw new ZError(11, 'account not exists')
}
let record = await CodeRecord.findByEmail(email, CodeType.RESET)
if (!record) {
throw new ZError(12, 'code not exists')
}
if (record.status !== CodeStatus.PENDING) {
throw new ZError(13, 'code expired')
}
if (record.code !== code) {
throw new ZError(14, 'code error')
}
account.updatePassword(password)
await account.save()
record.status = CodeStatus.SUCCESS
await record.save()
return {}
}
/**
*
*/
@role(ROLE_ANON)
@router('post /email/send_code')
async sendVerifyCode(req, res) {
let { email, type } = req.params
if (!email || !type) {
throw new ZError(10, 'params mismatch')
}
type = parseInt(type)
let record = await CodeRecord.findByEmail(email, type)
if (!record) {
record = new CodeRecord({ email, type, code: DEFAULT_CODE })
await record.save()
}
let html = type === CodeType.REGIST ? DEFAULT_REGIST_HTML : DEFAULT_RESET_HTML
let subject = type === CodeType.REGIST ? DEFAULT_REGIST_SUBJECT : DEFAULT_RESET_SUBJECT
subject = record.code + ' ' + subject
html = html.replace('{{ocde}}', record.code)
html = html.replace('{{time}}', new Date().format('yyyy-MM-dd hh:mm:ss'))
let msgData = {
to: email,
html,
subject,
}
try {
let result = await new EmailSvr().sendMail(msgData)
record.mailSend = true
record.emailId = result.messageId
record.expiredAt = Date.now() + DEFAULT_EXPIRE_TIME
await record.save()
} catch (err) {
logger.info(`error send mail:: emial: ${email}, type: ${type}`)
logger.error(err)
record.status = CodeStatus.FAIL
await record.save()
throw new ZError(14, 'send mail error')
}
return {}
}
/**
* email是否已经被注册
*/
@role(ROLE_ANON)
@router('post /email/check')
async checkMailExists(req, res) {
let { email } = req.params
if (!email) {
throw new ZError(10, 'params mismatch')
}
let account = await Account.findByEmail(email)
return { exists: !account }
}
}

View File

@ -6,7 +6,14 @@ import { DEFAULT_VERIFY_HTML, EmailSvr } from 'service/email.svr'
const TOKEN_PREFIX = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'
/**
* email
* 使
*/
class VerifyController extends BaseController {
/**
* email的邮件
*/
@router('post /email/verify')
async sendVerifyEmail(req, res) {
let user = req.user
@ -29,6 +36,9 @@ class VerifyController extends BaseController {
return result
}
/**
*
*/
@role(ROLE_ANON)
@router('get /email/verify')
async verifyEmail(req, res) {

View File

@ -1,7 +1,8 @@
import { getModelForClass, index, modelOptions, mongoose, prop, Severity } from '@typegoose/typegoose'
import { getModelForClass, index, modelOptions, mongoose, prop, ReturnModelType, Severity } from '@typegoose/typegoose'
import { dbconn } from 'decorators/dbconn'
import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'
import { BaseModule } from './Base'
import { genRandomString, sha512 } from 'utils/security.util'
export enum PlatEnum {
GOOGLE = 0,
@ -9,6 +10,29 @@ export enum PlatEnum {
TIKTOK = 2,
FACEBOOK = 3,
TWITTER = 4,
EMAIL = 5,
}
/**
* salt和hash
* @param userpassword
* @return {{salt: any, passwordHash: string}}
*/
export function saltHashPassword(userpassword: string) {
let salt = genRandomString(16)
return sha512(userpassword, salt)
}
/**
*
* @param userpassword
* @param passwordDb
* @param salt
* @return {boolean}
*/
export function verifyPass(userpassword: string, passwordDb: string, salt: string) {
let passwordData = sha512(userpassword, salt)
return passwordData.passwordHash === passwordDb
}
interface AccountClass extends Base, TimeStamps {}
@ -30,6 +54,12 @@ class AccountClass extends BaseModule {
*/
@prop()
public email?: string
@prop()
public password?: string
@prop()
public salt?: string
/**
* email认证信息
*/
@ -70,6 +100,22 @@ class AccountClass extends BaseModule {
@prop()
public emailVerifyTime?: number
public static async findByEmail(this: ReturnModelType<typeof AccountClass>, email) {
return this.findOne({ email, plat: PlatEnum.EMAIL }).exec()
}
public updatePassword(password: string) {
if (password) {
let passData = saltHashPassword(password)
this.password = passData.passwordHash
this.salt = passData.salt
}
}
public verifyPassword(password: string) {
return verifyPass(password, this.password, this.salt)
}
}
export const Account = getModelForClass(AccountClass, { existingConnection: AccountClass.db })

80
src/modules/CodeRecord.ts Normal file
View File

@ -0,0 +1,80 @@
import { getModelForClass, index, modelOptions, pre, prop, ReturnModelType } from '@typegoose/typegoose'
import { dbconn } from 'decorators/dbconn'
import { BaseModule } from './Base'
import { customAlphabet } from 'nanoid'
const nanoid = customAlphabet('1234567890', 6)
export const DEFAULT_CODE = '000000'
export const DEFAULT_EXPIRE_TIME = 5 * 60 * 1000
export enum CodeType {
REGIST = 1, // 注册
RESET = 2, // 重置密码
}
export enum CodeStatus {
PENDING = 1,
SUCCESS = 2,
FAIL = 3,
EXPIRED = 4,
}
/**
*
*/
@dbconn()
@index({ email: 1, type: 1 }, { unique: true, partialFilterExpression: { status: 1 } })
@index({ code: 1 }, { partialFilterExpression: { status: 1 } })
@modelOptions({
schemaOptions: { collection: 'code_send_record', timestamps: true },
})
@pre<CodeRecordClass>('save', async function () {
if (this.code === DEFAULT_CODE) {
let exists = false
while (!exists) {
const code = nanoid()
const record = await CodeRecord.findByCode(code, this.type)
if (!record) {
exists = true
this.code = code
}
}
}
})
class CodeRecordClass extends BaseModule {
@prop({ required: true })
public email!: string
@prop()
public account?: string
@prop({ required: true })
public code!: string
@prop({ default: Date.now() + DEFAULT_EXPIRE_TIME })
public expiredAt?: number
@prop({ required: true, default: CodeType.REGIST })
public type: CodeType
@prop({ required: true, default: CodeStatus.PENDING })
public status: CodeStatus
@prop({ default: false })
public mailSend: boolean
@prop()
public emailId?: string
public static async findByCode(this: ReturnModelType<typeof CodeRecordClass>, code: string, type: CodeType) {
return this.findOne({ code, type, status: CodeStatus.PENDING }).exec()
}
public static async findByEmail(this: ReturnModelType<typeof CodeRecordClass>, email: string, type: CodeType) {
return this.findOne({ email, type, status: CodeStatus.PENDING }).exec()
}
}
export const CodeRecord = getModelForClass(CodeRecordClass, { existingConnection: CodeRecordClass.db })

View File

@ -0,0 +1,19 @@
import { singleton } from 'decorators/singleton'
import { CodeRecord, CodeStatus } from 'modules/CodeRecord'
import * as schedule from 'node-schedule'
/**
*
*/
@singleton
export default class CodeTaskSchedule {
async parseAllRecord() {
let now = Date.now()
await CodeRecord.updateMany({ expiredAt: { $lt: now }, status: CodeStatus.PENDING }, { status: CodeStatus.EXPIRED })
}
scheduleAll() {
const job = schedule.scheduleJob('*/1 * * * *', async () => {
await this.parseAllRecord()
})
}
}

View File

@ -1,13 +1,31 @@
import {singleton} from "decorators/singleton";
import { NetClient} from "net/NetClient";
import { singleton } from 'decorators/singleton'
import { NetClient } from 'net/NetClient'
export const DEFAULT_VERIFY_HTML =
`
export const DEFAULT_VERIFY_HTML = `
<h1>Email Verification</h1>
<p>CEBG needs to confirm your email address is still valid. Please click the link below to confirm you received this mail.</p>
<p><a href="{{href}}" target="_blank">Verify Email</a></p>
<p>If you're worried about this email being legitimate, you can visit CEBG directly to confirm your email needs verifying. After doing so, please don't forget to click the link above.</p>
`
export const DEFAULT_REGIST_SUBJECT = 'CEBG regist code'
export const DEFAULT_REGIST_HTML = `
<h1>Your CEBG regist code is</h1>
<h1>{{ocde}}</h1>
<p>{{time}}</p>
<p>This is your one time code that expires in 5 minutes.</p>
<p>Use it to login CEBG. Never share this code with anyone.</p>
`
export const DEFAULT_RESET_SUBJECT = 'CEBG reset password code'
export const DEFAULT_RESET_HTML = `
<h1>Your CEBG reset password code is</h1>
<h1>{{ocde}}</h1>
<p>{{time}}</p>
<p>This is your one time code that expires in 5 minutes.</p>
<p>Use it to login CEBG. Never share this code with anyone.</p>
`
export interface IMailData {
from?: string
to: string
@ -19,7 +37,7 @@ export interface IMailData {
const DEFAULT_MSG_DATA: IMailData = {
from: 'CEBG <noreply@cebg.games>',
to: '',
subject: 'Please verify your email address'
subject: 'Please verify your email address',
}
const MAIL_SVR = 'http://127.0.0.1:3087'
@ -28,10 +46,9 @@ export class EmailSvr {
public sendMail(msg: IMailData) {
Object(DEFAULT_MSG_DATA).zssign(msg)
let reqData = {
url: MAIL_SVR+'/mail/send',
data: JSON.stringify({message: msg})
url: MAIL_SVR + '/mail/send',
data: JSON.stringify({ message: msg }),
}
return new NetClient().httpPost(reqData)
}
}

View File

@ -1,5 +1,5 @@
import crypto from 'crypto'
import {compressUuid} from './string.util'
import { compressUuid } from './string.util'
const ENCODER = 'base64'
const REG_KEY = /^[0-9a-fA-F]{63,64}$/
@ -24,6 +24,23 @@ export function aesDecrypt(encryptedText: string, password: string, iv: string)
return decrypted + decipher.final('utf8')
}
export function sha512(password: string, salt: string) {
let hash = crypto.createHmac('sha512', salt)
hash.update(password)
let value = hash.digest('hex')
return {
salt: salt,
passwordHash: value,
}
}
export function genRandomString(length: number) {
return crypto
.randomBytes(Math.ceil(length / 2))
.toString('hex')
.slice(0, length)
}
export function uuid() {
return crypto.randomUUID()
}

View File

@ -250,6 +250,13 @@
resolved "https://registry.npmmirror.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
"@types/node-schedule@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@types/node-schedule/-/node-schedule-2.1.0.tgz#60375640c0509bab963573def9d1f417f438c290"
integrity sha512-NiTwl8YN3v/1YCKrDFSmCTkVxFDylueEqsOFdgF+vPsm+AlyJKGAo5yzX1FiOxPsZiN6/r8gJitYx2EaSuBmmg==
dependencies:
"@types/node" "*"
"@types/node@*":
version "18.11.0"
resolved "https://registry.npmmirror.com/@types/node/-/node-18.11.0.tgz#f38c7139247a1d619f6cc6f27b072606af7c289d"
@ -679,6 +686,13 @@ create-require@^1.1.0:
resolved "https://registry.npmmirror.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cron-parser@^4.2.0:
version "4.8.1"
resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.8.1.tgz#47062ea63d21d78c10ddedb08ea4c5b6fc2750fb"
integrity sha512-jbokKWGcyU4gl6jAfX97E1gDpY12DJ1cLJZmoDzaAln/shZ+S3KBFBuA2Q6WeUN4gJf/8klnV1EfvhA2lK5IRQ==
dependencies:
luxon "^3.2.1"
cross-spawn@^7.0.2:
version "7.0.3"
resolved "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@ -1559,6 +1573,11 @@ loglevel@^1.8.0:
resolved "https://registry.npmmirror.com/loglevel/-/loglevel-1.8.0.tgz#e7ec73a57e1e7b419cb6c6ac06bf050b67356114"
integrity sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==
long-timeout@0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/long-timeout/-/long-timeout-0.1.1.tgz#9721d788b47e0bcb5a24c2e2bee1a0da55dab514"
integrity sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
@ -1582,6 +1601,11 @@ lru-memoizer@^2.1.4:
lodash.clonedeep "^4.5.0"
lru-cache "~4.0.0"
luxon@^3.2.1:
version "3.3.0"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.3.0.tgz#d73ab5b5d2b49a461c47cedbc7e73309b4805b48"
integrity sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
@ -1742,6 +1766,15 @@ node-forge@^1.3.1:
resolved "https://registry.npmmirror.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3"
integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==
node-schedule@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/node-schedule/-/node-schedule-2.1.1.tgz#6958b2c5af8834954f69bb0a7a97c62b97185de3"
integrity sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==
dependencies:
cron-parser "^4.2.0"
long-timeout "0.1.1"
sorted-array-functions "^1.3.0"
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
@ -2097,6 +2130,11 @@ sonic-boom@^3.1.0:
dependencies:
atomic-sleep "^1.0.0"
sorted-array-functions@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz#8605695563294dffb2c9796d602bd8459f7a0dd5"
integrity sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==
source-map-support@^0.5.12:
version "0.5.21"
resolved "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"