Browse Source

first commit

main
Nikky 4 years ago
commit
79638d1f80
  1. 123
      .gitignore
  2. 1590
      package-lock.json
  3. 20
      package.json
  4. 6
      src/config/database.js
  5. 45
      src/index.js
  6. 21
      src/logic/common.js
  7. 49
      src/logic/security.js
  8. 118
      src/misc/animals.js
  9. 21
      src/model/user.model.js
  10. 127
      src/route/auth.controller.js
  11. 28
      src/route/user.controller.js

123
.gitignore

@ -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.*

1590
package-lock.json

File diff suppressed because it is too large

20
package.json

@ -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"
}
}

6
src/config/database.js

@ -0,0 +1,6 @@
export const DatabaseConfig = {
dialect: 'sqlite',
storage: './database.sqlite',
logging: false
};

45
src/index.js

@ -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');
})();

21
src/logic/common.js

@ -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)];
}

49
src/logic/security.js

@ -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);
}

118
src/misc/animals.js

@ -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"
];

21
src/model/user.model.js

@ -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;

127
src/route/auth.controller.js

@ -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;

28
src/route/user.controller.js

@ -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…
Cancel
Save