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