Initial dashboard work (auth flow)

This commit is contained in:
Dragory 2019-05-26 00:13:42 +03:00
parent d54897acdd
commit 5a91d36953
18 changed files with 3808 additions and 31 deletions

2
.gitignore vendored
View file

@ -71,7 +71,9 @@ desktop.ini
# Compiled files # Compiled files
/dist /dist
/dist-frontend
# Misc # Misc
/convert.js /convert.js
/startscript.js /startscript.js
/.cache

View file

@ -0,0 +1,5 @@
{
"watch": "src/dashboard/api",
"ext": "ts",
"exec": "ts-node ./src/dashboard/api/index.ts"
}

3397
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,39 +5,43 @@
"private": true, "private": true,
"scripts": { "scripts": {
"test": "jest src", "test": "jest src",
"start-dev": "ts-node src/index.ts", "start-bot-dev": "ts-node src/index.ts",
"start-watch": "nodemon", "start-bot-watch": "nodemon --config nodemon-bot.json",
"start-bot-prod": "cross-env NODE_ENV=production node dist/index.js",
"format": "prettier --write \"./**/*.ts\"", "format": "prettier --write \"./**/*.ts\"",
"typeorm": "ts-node ./node_modules/typeorm/cli.js", "typeorm": "ts-node ./node_modules/typeorm/cli.js",
"build": "rimraf dist && tsc", "build": "rimraf dist && tsc",
"start-prod": "cross-env NODE_ENV=production node dist/index.js", "build-dashboard-frontend": "rimraf dist-frontend && parcel build src/dashboard/frontend/index.html --out-dir dist-frontend",
"migrate": "npm run typeorm -- migration:run" "start-dashboard-frontend-dev": "parcel src/dashboard/frontend/index.html",
"start-dashboard-api-dev": "ts-node src/dashboard/server/index.ts",
"start-dashboard-api-watch": "nodemon --config nodemon-dashboard-api.json",
"start-dashboard-api": "cross-env NODE_ENV=production node dist/dashboard/server/index.js",
"migrate": "npm run typeorm -- migration:run",
"migrate-rollback": "npm run typeorm -- migration:revert"
}, },
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": { "dependencies": {
"@types/lodash.at": "^4.6.3",
"@types/moment-timezone": "^0.5.6",
"ajv": "^6.7.0", "ajv": "^6.7.0",
"cross-env": "^5.2.0", "cross-env": "^5.2.0",
"dotenv": "^4.0.0", "dotenv": "^4.0.0",
"emoji-regex": "^7.0.1", "emoji-regex": "^7.0.1",
"eris": "github:abalabahaha/eris#dev", "eris": "github:abalabahaha/eris#dev",
"escape-string-regexp": "^1.0.5", "escape-string-regexp": "^1.0.5",
"express": "^4.17.0",
"humanize-duration": "^3.15.0", "humanize-duration": "^3.15.0",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"knub": "^20.1.0", "knub": "^20.1.0",
"last-commit-log": "^2.1.0", "last-commit-log": "^2.1.0",
"lodash.at": "^4.6.0",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.difference": "^4.5.0", "lodash.difference": "^4.5.0",
"lodash.has": "^4.5.2",
"lodash.intersection": "^4.4.0", "lodash.intersection": "^4.4.0",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"lodash.pick": "^4.4.0",
"moment-timezone": "^0.5.21", "moment-timezone": "^0.5.21",
"mysql": "^2.16.0", "mysql": "^2.16.0",
"passport": "^0.4.0",
"passport-custom": "^1.0.5",
"passport-oauth2": "^1.5.0",
"reflect-metadata": "^0.1.12", "reflect-metadata": "^0.1.12",
"tlds": "^1.203.1", "tlds": "^1.203.1",
"tmp": "0.0.33", "tmp": "0.0.33",
@ -50,14 +54,20 @@
"@babel/core": "^7.3.4", "@babel/core": "^7.3.4",
"@babel/preset-env": "^7.3.4", "@babel/preset-env": "^7.3.4",
"@babel/preset-typescript": "^7.3.3", "@babel/preset-typescript": "^7.3.3",
"@types/express": "^4.16.1",
"@types/jest": "^24.0.11", "@types/jest": "^24.0.11",
"@types/lodash.at": "^4.6.3",
"@types/moment-timezone": "^0.5.6",
"@types/node": "^10.12.0", "@types/node": "^10.12.0",
"@types/passport": "^1.0.0",
"@types/passport-oauth2": "^1.4.8",
"@types/tmp": "0.0.33", "@types/tmp": "0.0.33",
"babel-jest": "^24.5.0", "babel-jest": "^24.5.0",
"husky": "^1.3.1", "husky": "^1.3.1",
"jest": "^24.7.1", "jest": "^24.7.1",
"lint-staged": "^8.1.5", "lint-staged": "^8.1.5",
"nodemon": "^1.17.5", "nodemon": "^1.17.5",
"parcel-bundler": "^1.12.3",
"prettier": "^1.16.4", "prettier": "^1.16.4",
"rimraf": "^2.6.2", "rimraf": "^2.6.2",
"tslint": "^5.13.1", "tslint": "^5.13.1",

View file

@ -3,7 +3,7 @@
{ {
"name": "zeppelin", "name": "zeppelin",
"script": "npm", "script": "npm",
"args": "run start-prod", "args": "run start-bot-prod",
"log_date_format": "YYYY-MM-DD HH:mm:ss" "log_date_format": "YYYY-MM-DD HH:mm:ss"
} }
] ]

104
src/dashboard/api/auth.ts Normal file
View file

@ -0,0 +1,104 @@
import express from "express";
import passport from "passport";
import OAuth2Strategy from "passport-oauth2";
import CustomStrategy from "passport-custom";
import { DashboardLogins, DashboardLoginUserData } from "../../data/DashboardLogins";
import pick from "lodash.pick";
import https from "https";
const DISCORD_API_URL = "https://discordapp.com/api";
function simpleAPIRequest(bearerToken, path): Promise<any> {
return new Promise((resolve, reject) => {
const request = https.get(
`${DISCORD_API_URL}/${path}`,
{
headers: {
Authorization: `Bearer ${bearerToken}`,
},
},
res => {
if (res.statusCode !== 200) {
reject(new Error(`Discord API error ${res.statusCode}`));
return;
}
res.on("data", data => resolve(JSON.parse(data)));
},
);
request.on("error", err => reject(err));
});
}
export default function initAuth(app: express.Express) {
app.use(passport.initialize());
if (!process.env.CLIENT_ID) {
throw new Error("Auth: CLIENT ID missing");
}
if (!process.env.CLIENT_SECRET) {
throw new Error("Auth: CLIENT SECRET missing");
}
if (!process.env.OAUTH_CALLBACK_URL) {
throw new Error("Auth: OAUTH CALLBACK URL missing");
}
if (!process.env.DASHBOARD_URL) {
throw new Error("DASHBOARD_URL missing!");
}
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));
const dashboardLogins = new DashboardLogins();
// Initialize API tokens
passport.use(
"api-token",
new CustomStrategy(async (req, cb) => {
console.log("in api-token strategy");
const apiKey = req.header("X-Api-Key");
if (!apiKey) return cb();
const userId = await dashboardLogins.getUserIdByApiKey(apiKey);
if (userId) {
cb(null, { userId });
}
cb();
}),
);
// Initialize OAuth2 for Discord login
passport.use(
new OAuth2Strategy(
{
authorizationURL: "https://discordapp.com/api/oauth2/authorize",
tokenURL: "https://discordapp.com/api/oauth2/token",
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: process.env.OAUTH_CALLBACK_URL,
scope: ["identify"],
},
async (accessToken, refreshToken, profile, cb) => {
const user = await simpleAPIRequest(accessToken, "users/@me");
const userData = pick(user, ["username", "discriminator", "avatar"]) as DashboardLoginUserData;
const apiKey = await dashboardLogins.addLogin(user.id, userData);
// TODO: Revoke access token, we don't need it anymore
cb(null, { apiKey });
},
),
);
app.get("/auth/login", passport.authenticate("oauth2"));
app.get(
"/auth/oauth-callback",
passport.authenticate("oauth2", { failureRedirect: "/", session: false }),
(req, res) => {
res.redirect(`${process.env.DASHBOARD_URL}/login-callback/?apiKey=${req.user.apiKey}`);
},
);
}

View file

@ -0,0 +1,19 @@
require("dotenv").config();
import express from "express";
import initAuth from "./auth";
import { connect } from "../../data/db";
console.log("Connecting to database...");
connect().then(() => {
const app = express();
initAuth(app);
app.get("/", (req, res) => {
res.end("Hi");
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`API server listening on port ${port}`));
});

View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Zeppelin Dashboard</title>
</head>
<body>
<button id="login-button">Login</button>
<script src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,4 @@
const API_URL = process.env.API_URL;
document.getElementById('login-button').addEventListener('click', () => {
window.location.href = `${API_URL}/auth/login`;
});

View file

@ -0,0 +1,85 @@
import { getRepository, Repository } from "typeorm";
import { DashboardLogin } from "./entities/DashboardLogin";
import { BaseRepository } from "./BaseRepository";
import crypto from "crypto";
import moment from "moment-timezone";
// tslint:disable-next-line:no-submodule-imports
import uuidv4 from "uuid/v4";
import { DBDateFormat } from "../utils";
import { log } from "util";
export interface DashboardLoginUserData {
username: string;
discriminator: string;
avatar: string;
}
export class DashboardLogins extends BaseRepository {
private dashboardLogins: Repository<DashboardLogin>;
constructor() {
super();
this.dashboardLogins = getRepository(DashboardLogin);
}
async getUserIdByApiKey(apiKey: string): Promise<string | null> {
const [loginId, token] = apiKey.split(".");
if (!loginId || !token) {
return null;
}
const login = await this.dashboardLogins
.createQueryBuilder()
.where("id = :id", { id: loginId })
.where("expires_at > NOW()")
.getOne();
if (!login) {
return null;
}
const hash = crypto.createHash("sha256");
hash.update(token);
const hashedToken = hash.digest("hex");
if (hashedToken !== login.token) {
return null;
}
return login.user_id;
}
async addLogin(userId: string, userData: DashboardLoginUserData): Promise<string> {
// Generate random login id
let loginId;
while (true) {
loginId = uuidv4();
const existing = await this.dashboardLogins.findOne({
where: {
id: loginId,
},
});
if (!existing) break;
}
// Generate token
const token = uuidv4();
const hash = crypto.createHash("sha256");
hash.update(token);
const hashedToken = hash.digest("hex");
// Save this to the DB
await this.dashboardLogins.insert({
id: loginId,
token: hashedToken,
user_id: userId,
user_data: userData,
logged_in_at: moment().format(DBDateFormat),
expires_at: moment()
.add(1, "day")
.format(DBDateFormat),
});
return `${loginId}.${token}`;
}
}

View file

@ -0,0 +1,12 @@
import { getRepository, Repository } from "typeorm";
import { DashboardUser } from "./entities/DashboardUser";
import { BaseRepository } from "./BaseRepository";
export class DashboardUsers extends BaseRepository {
private dashboardUsers: Repository<DashboardUser>;
constructor() {
super();
this.dashboardUsers = getRepository(DashboardUser);
}
}

View file

@ -0,0 +1,24 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
import { DashboardLoginUserData } from "../DashboardLogins";
@Entity("dashboard_logins")
export class DashboardLogin {
@Column()
@PrimaryColumn()
id: string;
@Column()
token: string;
@Column()
user_id: string;
@Column("simple-json")
user_data: DashboardLoginUserData;
@Column()
logged_in_at: string;
@Column()
expires_at: string;
}

View file

@ -0,0 +1,18 @@
import { Entity, Column, PrimaryColumn } from "typeorm";
@Entity("dashboard_users")
export class DashboardUser {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
user_id: string;
@Column()
username: string;
@Column()
role: string;
}

View file

@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner, Table } from "typeorm";
export class CreateDashboardLoginsTable1558804433320 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.createTable(
new Table({
name: "dashboard_logins",
columns: [
{
name: "id",
type: "varchar",
length: "36",
isPrimary: true,
collation: "ascii_bin",
},
{
name: "token",
type: "varchar",
length: "64",
collation: "ascii_bin",
},
{
name: "user_id",
type: "bigint",
},
{
name: "user_data",
type: "text",
},
{
name: "logged_in_at",
type: "DATETIME",
},
{
name: "expires_at",
type: "DATETIME",
},
],
indices: [
{
columnNames: ["user_id"],
},
{
columnNames: ["expires_at"],
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropTable("dashboard_logins", true);
}
}

View file

@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";
export class CreateDashboardUsersTable1558804449510 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.createTable(
new Table({
name: "dashboard_users",
columns: [
{
name: "guild_id",
type: "bigint",
},
{
name: "user_id",
type: "bigint",
},
{
name: "username",
type: "varchar",
length: "255",
},
{
name: "role",
type: "varchar",
length: "32",
},
],
}),
);
await queryRunner.createPrimaryKey("dashboard_users", ["guild_id", "user_id"]);
await queryRunner.createIndex(
"dashboard_users",
new TableIndex({
columnNames: ["user_id"],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropTable("dashboard_users", true);
}
}

View file

@ -1,5 +1,4 @@
import has from "lodash.has"; import { has, get } from "./utils";
import at from "lodash.at";
const TEMPLATE_CACHE_SIZE = 200; const TEMPLATE_CACHE_SIZE = 200;
const templateCache: Map<string, ParsedTemplate> = new Map(); const templateCache: Map<string, ParsedTemplate> = new Map();
@ -219,7 +218,7 @@ export function parseTemplate(str: string): ParsedTemplate {
} }
async function evaluateTemplateVariable(theVar: ITemplateVar, values) { async function evaluateTemplateVariable(theVar: ITemplateVar, values) {
let value = has(values, theVar.identifier) ? at(values, theVar.identifier)[0] : undefined; const value = has(values, theVar.identifier) ? get(values, theVar.identifier)[0] : undefined;
if (typeof value === "function") { if (typeof value === "function") {
const args = []; const args = [];

View file

@ -5,7 +5,6 @@ import {
Guild, Guild,
GuildAuditLogEntry, GuildAuditLogEntry,
Member, Member,
MessageContent,
TextableChannel, TextableChannel,
TextChannel, TextChannel,
User, User,
@ -61,8 +60,19 @@ export function errorMessage(str) {
return `${str}`; return `${str}`;
} }
export function uclower(str) { export function get(obj, path, def?): any {
return str[0].toLowerCase() + str.slice(1); let cursor = obj;
const pathParts = path.split(".");
for (const part of pathParts) {
cursor = cursor[part];
if (cursor === undefined) return def;
if (cursor == null) return null;
}
return cursor;
}
export function has(obj, path): boolean {
return get(obj, path) !== undefined;
} }
export function stripObjectToScalars(obj, includedNested: string[] = []) { export function stripObjectToScalars(obj, includedNested: string[] = []) {

View file

@ -20,6 +20,7 @@
"severity": "warning" "severity": "warning"
}, },
"no-bitwise": false, "no-bitwise": false,
"interface-over-type-literal": false "interface-over-type-literal": false,
"interface-name": false
} }
} }