commit
79638d1f80
11 changed files with 2148 additions and 0 deletions
@ -0,0 +1,123 @@ |
|||||
|
*.sqlite |
||||
|
notes.txt |
||||
|
database.sqlite |
||||
|
database.sqlite-journal |
||||
|
|
||||
|
# Logs |
||||
|
logs |
||||
|
*.log |
||||
|
npm-debug.log* |
||||
|
yarn-debug.log* |
||||
|
yarn-error.log* |
||||
|
lerna-debug.log* |
||||
|
.pnpm-debug.log* |
||||
|
|
||||
|
# Diagnostic reports (https://nodejs.org/api/report.html) |
||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json |
||||
|
|
||||
|
# Runtime data |
||||
|
pids |
||||
|
*.pid |
||||
|
*.seed |
||||
|
*.pid.lock |
||||
|
|
||||
|
# Directory for instrumented libs generated by jscoverage/JSCover |
||||
|
lib-cov |
||||
|
|
||||
|
# Coverage directory used by tools like istanbul |
||||
|
coverage |
||||
|
*.lcov |
||||
|
|
||||
|
# nyc test coverage |
||||
|
.nyc_output |
||||
|
|
||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) |
||||
|
.grunt |
||||
|
|
||||
|
# Bower dependency directory (https://bower.io/) |
||||
|
bower_components |
||||
|
|
||||
|
# node-waf configuration |
||||
|
.lock-wscript |
||||
|
|
||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html) |
||||
|
build/Release |
||||
|
|
||||
|
# Dependency directories |
||||
|
node_modules/ |
||||
|
jspm_packages/ |
||||
|
|
||||
|
# Snowpack dependency directory (https://snowpack.dev/) |
||||
|
web_modules/ |
||||
|
|
||||
|
# TypeScript cache |
||||
|
*.tsbuildinfo |
||||
|
|
||||
|
# Optional npm cache directory |
||||
|
.npm |
||||
|
|
||||
|
# Optional eslint cache |
||||
|
.eslintcache |
||||
|
|
||||
|
# Microbundle cache |
||||
|
.rpt2_cache/ |
||||
|
.rts2_cache_cjs/ |
||||
|
.rts2_cache_es/ |
||||
|
.rts2_cache_umd/ |
||||
|
|
||||
|
# Optional REPL history |
||||
|
.node_repl_history |
||||
|
|
||||
|
# Output of 'npm pack' |
||||
|
*.tgz |
||||
|
|
||||
|
# Yarn Integrity file |
||||
|
.yarn-integrity |
||||
|
|
||||
|
# dotenv environment variables file |
||||
|
.env |
||||
|
.env.test |
||||
|
.env.production |
||||
|
|
||||
|
# parcel-bundler cache (https://parceljs.org/) |
||||
|
.cache |
||||
|
.parcel-cache |
||||
|
|
||||
|
# Next.js build output |
||||
|
.next |
||||
|
out |
||||
|
|
||||
|
# Nuxt.js build / generate output |
||||
|
.nuxt |
||||
|
dist |
||||
|
|
||||
|
# Gatsby files |
||||
|
.cache/ |
||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js |
||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support |
||||
|
# public |
||||
|
|
||||
|
# vuepress build output |
||||
|
.vuepress/dist |
||||
|
|
||||
|
# Serverless directories |
||||
|
.serverless/ |
||||
|
|
||||
|
# FuseBox cache |
||||
|
.fusebox/ |
||||
|
|
||||
|
# DynamoDB Local files |
||||
|
.dynamodb/ |
||||
|
|
||||
|
# TernJS port file |
||||
|
.tern-port |
||||
|
|
||||
|
# Stores VSCode versions used for testing VSCode extensions |
||||
|
.vscode-test |
||||
|
|
||||
|
# yarn v2 |
||||
|
.yarn/cache |
||||
|
.yarn/unplugged |
||||
|
.yarn/build-state.yml |
||||
|
.yarn/install-state.gz |
||||
|
.pnp.* |
||||
File diff suppressed because it is too large
@ -0,0 +1,20 @@ |
|||||
|
{ |
||||
|
"name": "kuroshio-server", |
||||
|
"version": "1.0.0", |
||||
|
"description": "", |
||||
|
"main": "src/index.js", |
||||
|
"type": "module", |
||||
|
"scripts": { |
||||
|
"test": "echo \"Error: no test specified\" && exit 1" |
||||
|
}, |
||||
|
"author": "", |
||||
|
"license": "ISC", |
||||
|
"dependencies": { |
||||
|
"bcrypt": "^5.0.1", |
||||
|
"fastify": "^3.22.0", |
||||
|
"fastify-formbody": "^5.1.0", |
||||
|
"fastify-multipart": "^5.0.2", |
||||
|
"sequelize": "^6.7.0", |
||||
|
"sqlite3": "^5.0.2" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
|
||||
|
export const DatabaseConfig = { |
||||
|
dialect: 'sqlite', |
||||
|
storage: './database.sqlite', |
||||
|
logging: false |
||||
|
}; |
||||
@ -0,0 +1,45 @@ |
|||||
|
import Fastify from 'fastify' |
||||
|
import formBodyPlugin from 'fastify-formbody'; |
||||
|
import fastifyMultipart from 'fastify-multipart'; |
||||
|
import Sequelize from 'sequelize'; |
||||
|
import { DatabaseConfig } from './config/database.js'; |
||||
|
import UserEntity from './model/user.model.js'; |
||||
|
import AuthController from './route/auth.controller.js'; |
||||
|
import UserController from './route/user.controller.js'; |
||||
|
|
||||
|
const app = Fastify({logger: false}); |
||||
|
const db = new Sequelize(DatabaseConfig); |
||||
|
|
||||
|
async function Database(){ |
||||
|
{ //models
|
||||
|
const opts = {db}; |
||||
|
UserEntity(opts); |
||||
|
} |
||||
|
await db.authenticate(); |
||||
|
await db.sync({alter: true}); |
||||
|
} |
||||
|
|
||||
|
async function WebApp(){ |
||||
|
app.register(formBodyPlugin); |
||||
|
app.register(fastifyMultipart); |
||||
|
{ //routes, bloody routes
|
||||
|
const opts = {app, db}; |
||||
|
AuthController(opts); |
||||
|
UserController(opts); |
||||
|
} |
||||
|
app.get('/', () => 'Hello. D:'); |
||||
|
const options = { port: 3000, host: '127.0.0.1' }; |
||||
|
const address = await app.listen(options); |
||||
|
return `Server is listening on ${address}.`; |
||||
|
} |
||||
|
|
||||
|
(async () => { |
||||
|
//initialize
|
||||
|
const step = async (func, name) => { |
||||
|
let start = Date.now(); |
||||
|
const msg = await func(); |
||||
|
console.log(`[${name}] (${(Date.now() - start)} ms) ${msg || ''}`); |
||||
|
}; |
||||
|
await step(Database, 'DB'); |
||||
|
await step(WebApp, 'Fastify'); |
||||
|
})(); |
||||
@ -0,0 +1,21 @@ |
|||||
|
|
||||
|
function isString(val){ |
||||
|
return (typeof val) === 'string'; |
||||
|
} |
||||
|
|
||||
|
export function checkStringParam(param, min, max){ |
||||
|
return isString(param) && (param.length >= min) && (param.length <= max); |
||||
|
} |
||||
|
|
||||
|
export function errorOut(reply, msg, code){ |
||||
|
reply.code(code || 400); |
||||
|
reply.send(msg) || 'Bad request.'; |
||||
|
} |
||||
|
|
||||
|
export function reverseString(str){ |
||||
|
return str.split("").reverse().join(""); |
||||
|
} |
||||
|
|
||||
|
export function randomElement(arr){ |
||||
|
return arr[Math.floor(Math.random()*arr.length)]; |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
import bcrypt from 'bcrypt'; |
||||
|
import crypto from 'crypto'; |
||||
|
|
||||
|
//-- endpoints --
|
||||
|
|
||||
|
export function isEndpointProtected(url){ |
||||
|
//is authentication required
|
||||
|
return (url.substring(0, 6) !== '/auth/') && (url !== '/'); |
||||
|
} |
||||
|
|
||||
|
export function isEndpointAllowedForBannedUsers(url){ |
||||
|
return (url === '/users/me'); |
||||
|
} |
||||
|
|
||||
|
//-- user password --
|
||||
|
|
||||
|
export function hashPassword(paswd){ |
||||
|
return bcrypt.hashSync(paswd, 10); |
||||
|
} |
||||
|
|
||||
|
export function doesPasswordMatch(paswd, hash){ |
||||
|
return bcrypt.compareSync(paswd, hash); |
||||
|
} |
||||
|
|
||||
|
//-- access tokens --
|
||||
|
|
||||
|
const days = 24*60*60*1000; |
||||
|
export const tokenLifetime = 90 * days; |
||||
|
export const tokenReissue = 60 * days; |
||||
|
|
||||
|
export async function reissueToken(user, Users){ |
||||
|
const token = generateToken(); |
||||
|
const tokenExpiry = newTokenExpiry(); |
||||
|
await Users.update({token, tokenExpiry}, {where: {id: user.id}}); |
||||
|
return token; |
||||
|
} |
||||
|
|
||||
|
export function generateToken(){ |
||||
|
return ( |
||||
|
crypto.randomBytes(16).toString('hex') |
||||
|
+ ((++tokenTempCounter).toString(16))+'g' |
||||
|
+ (Date.now().toString(16)) |
||||
|
); |
||||
|
} |
||||
|
let tokenTempCounter = 0; |
||||
|
|
||||
|
export function newTokenExpiry(){ |
||||
|
return new Date(Date.now() + tokenLifetime); |
||||
|
} |
||||
@ -0,0 +1,118 @@ |
|||||
|
|
||||
|
export const Animals = [ |
||||
|
"ammocoete", |
||||
|
"antling", |
||||
|
"apelet", |
||||
|
"apeling", |
||||
|
"bardie", |
||||
|
"batling", |
||||
|
"bear cub", |
||||
|
"bear-whelp", |
||||
|
"bearling", |
||||
|
"beaverkin", |
||||
|
"beaverling", |
||||
|
"birdikin", |
||||
|
"brandling", |
||||
|
"bull-pup", |
||||
|
"bullet", |
||||
|
"bullock", |
||||
|
"bum calf", |
||||
|
"bummer", |
||||
|
"bunny", |
||||
|
"calf", |
||||
|
"caterpillar", |
||||
|
"catling", |
||||
|
"chick", |
||||
|
"chickling", |
||||
|
"cockling", |
||||
|
"codling", |
||||
|
"colt", |
||||
|
"cowlet", |
||||
|
"cowling", |
||||
|
"crablet", |
||||
|
"cria", |
||||
|
"cygnet", |
||||
|
"doeling", |
||||
|
"dogie", |
||||
|
"doodlebug", |
||||
|
"dovelet", |
||||
|
"doveling", |
||||
|
"ducklet", |
||||
|
"duckling", |
||||
|
"eaglet", |
||||
|
"elver", |
||||
|
"falconet", |
||||
|
"fausen", |
||||
|
"fawn", |
||||
|
"filly", |
||||
|
"fishling", |
||||
|
"flapper", |
||||
|
"fledgling", |
||||
|
"foal", |
||||
|
"fox cub", |
||||
|
"foxling", |
||||
|
"frogling", |
||||
|
"fry", |
||||
|
"goatling", |
||||
|
"goatrel", |
||||
|
"gooseling", |
||||
|
"gosling", |
||||
|
"hatchling", |
||||
|
"hoglet", |
||||
|
"horseling", |
||||
|
"joey", |
||||
|
"kit", |
||||
|
"kitling", |
||||
|
"kitten", |
||||
|
"kitty", |
||||
|
"lamb", |
||||
|
"larva", |
||||
|
"leafworm", |
||||
|
"leppy", |
||||
|
"leptocephalus", |
||||
|
"leveret", |
||||
|
"lion cub", |
||||
|
"lionet", |
||||
|
"mallishag", |
||||
|
"nauplius", |
||||
|
"nipper", |
||||
|
"owlet", |
||||
|
"owling", |
||||
|
"parr", |
||||
|
"peachick", |
||||
|
"piglet", |
||||
|
"pinky", |
||||
|
"piper", |
||||
|
"pluteus", |
||||
|
"polliwog", |
||||
|
"porcupette", |
||||
|
"puggle", |
||||
|
"pullet", |
||||
|
"pup", |
||||
|
"salmon peel", |
||||
|
"scaurie", |
||||
|
"scaury", |
||||
|
"scorpling", |
||||
|
"sharkling", |
||||
|
"sheepling", |
||||
|
"silkworm", |
||||
|
"siredon", |
||||
|
"skaddon", |
||||
|
"snakelet", |
||||
|
"snakeling", |
||||
|
"sounder", |
||||
|
"spiderling", |
||||
|
"squab", |
||||
|
"squeaker", |
||||
|
"storkling", |
||||
|
"suckling", |
||||
|
"swanling", |
||||
|
"tadpole", |
||||
|
"tarrock", |
||||
|
"tiger cub", |
||||
|
"turkey pout", |
||||
|
"turtling", |
||||
|
"whelp", |
||||
|
"wolf cub", |
||||
|
"yeanling" |
||||
|
]; |
||||
@ -0,0 +1,21 @@ |
|||||
|
import Sequelize from 'sequelize'; |
||||
|
|
||||
|
const UserEntity = ({db}) => ( |
||||
|
db.define('Users', { |
||||
|
uuid: {type: Sequelize.DataTypes.UUID, defaultValue: Sequelize.UUIDV4}, |
||||
|
token: Sequelize.TEXT, |
||||
|
tokenExpiry: Sequelize.DATE, |
||||
|
email: Sequelize.TEXT, |
||||
|
username: Sequelize.TEXT, |
||||
|
paswd: Sequelize.TEXT, |
||||
|
role: Sequelize.TEXT, |
||||
|
firstIp: Sequelize.TEXT, |
||||
|
bannedUntil: Sequelize.DATE, |
||||
|
banReason: Sequelize.TEXT |
||||
|
|
||||
|
// emailed_last: { type: Sequelize.DATE, defaultValue: null },
|
||||
|
// visited_times: { type: Sequelize.INTEGER, defaultValue: 0 }
|
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
export default UserEntity; |
||||
@ -0,0 +1,127 @@ |
|||||
|
import { checkStringParam, errorOut, randomElement, reverseString } from '../logic/common.js'; |
||||
|
import { Animals } from '../misc/animals.js'; |
||||
|
import { reissueToken, generateToken, newTokenExpiry, hashPassword, doesPasswordMatch } from '../logic/security.js'; |
||||
|
|
||||
|
/** |
||||
|
* @param {Object} props |
||||
|
* @param {import('fastify').FastifyInstance} props.app |
||||
|
* @param {import('sequelize/types').Sequelize} props.db |
||||
|
*/ |
||||
|
|
||||
|
function AuthController({app, db}){ |
||||
|
const { Users } = db.models; |
||||
|
|
||||
|
{ //validate token header and put .user in every request
|
||||
|
app.decorateRequest('user', null); |
||||
|
app.addHook('preHandler', async (request, reply) => { |
||||
|
const unprotectedUrl = (request.url.substring(0, 6) == '/auth/') || (request.url === '/'); |
||||
|
|
||||
|
if(!unprotectedUrl){ |
||||
|
const token = request.headers.authorization; |
||||
|
//todo: local cache
|
||||
|
|
||||
|
const user = await Users.findOne({where: {token}}); |
||||
|
const tokenNotExpired = user && (new Date() <= user.tokenExpiry); |
||||
|
const userNotBanned = true; |
||||
|
if(tokenNotExpired && userNotBanned){ |
||||
|
request.user = user; |
||||
|
}else{ |
||||
|
errorOut(reply, 'Unauthorized', 401); |
||||
|
return true; //abort
|
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
app.post('/auth/sign-in', async (request) => { |
||||
|
const {email, paswd} = request.body || {}; |
||||
|
//todo: spam check
|
||||
|
|
||||
|
{ //form validation
|
||||
|
if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); |
||||
|
if(!isValidPassword(paswd)) return errorOut(reply, 'auth.bad_paswd'); |
||||
|
} |
||||
|
const fixedEmail = fixEmail(email); |
||||
|
const user = await Users.findOne({where: {email: fixedEmail}}); |
||||
|
|
||||
|
if(user && doesPasswordMatch(reverseString(paswd), user.paswd)){ |
||||
|
let token = user.token; |
||||
|
if(new Date() > user.tokenExpiry){ |
||||
|
token = await reissueToken(user, Users); |
||||
|
} |
||||
|
return {token}; |
||||
|
}else{ |
||||
|
errorOut(reply, 'auth.not_found'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
app.post('/auth/sign-up', async (request, reply) => { |
||||
|
const {email, username, paswd} = request.body || {}; |
||||
|
//todo: spam check
|
||||
|
|
||||
|
{ //form validation
|
||||
|
if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); |
||||
|
if(!isValidPassword(paswd)) return errorOut(reply, 'auth.bad_paswd'); |
||||
|
if(!isValidUsername(username)) return errorOut(reply, 'auth.bad_username'); |
||||
|
} |
||||
|
const fixedEmail = fixEmail(email); |
||||
|
const fixedUsername = fixUsername(username); |
||||
|
|
||||
|
{ //check if email is already in use
|
||||
|
const existingUser = await Users.findOne({where: {email: fixedEmail}}); |
||||
|
if(existingUser) return errorOut(reply, 'auth.email_taken'); |
||||
|
} |
||||
|
{ //check if username taken
|
||||
|
const existingUser = await Users.findOne({where: {username: fixedUsername}}); |
||||
|
if(existingUser) return errorOut(reply, 'auth.username_taken'); |
||||
|
} |
||||
|
{ //check if this ip already registered a bunch of accounts
|
||||
|
const countByIp = await Users.count({where: {firstIp: request.ip}}); |
||||
|
if(countByIp >= 10) return errorOut(reply, 'error.suspicious'); |
||||
|
} |
||||
|
const newUser = { |
||||
|
email: fixedEmail, |
||||
|
username: fixedUsername, |
||||
|
token: generateToken(), |
||||
|
tokenExpiry: newTokenExpiry(), |
||||
|
paswd: hashPassword(reverseString(paswd)), |
||||
|
role: 'user', |
||||
|
//firstIp: request.ip
|
||||
|
firstIp: request.hostname |
||||
|
}; |
||||
|
await Users.create(newUser); |
||||
|
return {token: newUser.token}; |
||||
|
}); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
function fixEmail(email){ |
||||
|
const plusPortion = /\+.*@/m; //gmail exploit
|
||||
|
return email.toLowerCase().replace(plusPortion, ''); |
||||
|
} |
||||
|
|
||||
|
function fixUsername(username){ |
||||
|
if(username){ |
||||
|
const profanities = /nigger|nigga|faggot/gi; |
||||
|
const badSymbols = /[\x00-\x1F\xCC\xCD]/g; |
||||
|
return username.replace(profanities, '💩').replace(badSymbols, ''); |
||||
|
}else{ |
||||
|
const nick = randomElement(Animals); |
||||
|
const tag = Date.now().toString(10).slice(-4); |
||||
|
return `Little ${nick} #${tag}`; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function isValidEmail(email){ |
||||
|
return checkStringParam(email, 3, 128) && email.match('@'); |
||||
|
} |
||||
|
|
||||
|
function isValidUsername(username){ |
||||
|
return (!username) || checkStringParam(username, 3, 64); |
||||
|
} |
||||
|
|
||||
|
function isValidPassword(paswd){ |
||||
|
return checkStringParam(paswd, 6, 256); |
||||
|
} |
||||
|
|
||||
|
export default AuthController; |
||||
@ -0,0 +1,28 @@ |
|||||
|
import { reissueToken, tokenLifetime, tokenReissue } from '../logic/security.js'; |
||||
|
|
||||
|
/** |
||||
|
* @param {Object} props |
||||
|
* @param {import('fastify').FastifyInstance} props.app |
||||
|
* @param {import('sequelize/types').Sequelize} props.db |
||||
|
*/ |
||||
|
|
||||
|
function UserController({app, db}){ |
||||
|
const { Users } = db.models; |
||||
|
|
||||
|
app.get('/users/me', async (request, reply) => { |
||||
|
const {user} = request; |
||||
|
const needsReissue = ((user.tokenExpiry - new Date()) < (tokenLifetime - tokenReissue)); |
||||
|
let newToken = needsReissue ? await reissueToken(user, Users) : null; |
||||
|
|
||||
|
return { |
||||
|
username: user.username, |
||||
|
role: user.role, |
||||
|
bannedUntil: user.bannedUntil, |
||||
|
banReason: user.banReason, |
||||
|
newToken |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
export default UserController; |
||||
Loading…
Reference in new issue