From f30ca78e40625d7c0f5097be4d442b0c0821d33d Mon Sep 17 00:00:00 2001 From: Kata Date: Sun, 10 Mar 2019 15:22:05 -0500 Subject: [PATCH] add AuthService as a DM authentication handler. (NEEDS SECURITY PASS) --- Roleypoly.js | 5 +++- api/auth.js | 24 ++++++++++++++++- logger.js | 4 ++- models/AuthChallenge.js | 14 ++++++++++ services/auth.js | 51 +++++++++++++++++++++++++++++++++++ services/discord.js | 60 ++++++++++++++++++++++++++++++++++++++++- 6 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 models/AuthChallenge.js create mode 100644 services/auth.js diff --git a/Roleypoly.js b/Roleypoly.js index c8d1c99..305eb56 100644 --- a/Roleypoly.js +++ b/Roleypoly.js @@ -8,6 +8,7 @@ import logger from './logger' import ServerService from './services/server' import DiscordService from './services/discord' import SessionService from './services/sessions' +import AuthService from './services/auth' import PresentationService from './services/presentation' import RPCServer from './rpc' import fetchModels, { type Models } from './models' @@ -43,7 +44,8 @@ export type AppContext = { P: PresentationService, RPC: RPCServer, M: Models, - sql: Sequelize + sql: Sequelize, + auth: AuthService } class Roleypoly { @@ -112,6 +114,7 @@ class Roleypoly { this.ctx.server = new ServerService(this.ctx) this.ctx.discord = new DiscordService(this.ctx) this.ctx.sessions = new SessionService(this.ctx) + this.ctx.auth = new AuthService(this.ctx) this.ctx.P = new PresentationService(this.ctx) this.ctx.RPC = new RPCServer(this) } diff --git a/api/auth.js b/api/auth.js index a65a2ae..984f76c 100644 --- a/api/auth.js +++ b/api/auth.js @@ -2,6 +2,8 @@ import { type Context } from 'koa' import { type AppContext, type Router } from '../Roleypoly' import ksuid from 'ksuid' +import logger from '../logger' +const log = logger(__filename) export default (R: Router, $: AppContext) => { R.post('/api/auth/token', async (ctx: Context) => { @@ -35,7 +37,7 @@ export default (R: Router, $: AppContext) => { }) R.get('/api/auth/user', async (ctx: Context) => { - const { accessToken } = (ctx.session: { accessToken?: string }) + const { accessToken } = ((ctx.session: any): { accessToken?: string }) if (accessToken === undefined) { ctx.body = { err: 'not_logged_in' } ctx.status = 401 @@ -81,4 +83,24 @@ export default (R: Router, $: AppContext) => { R.get('/api/oauth/bot/callback', async (ctx: Context) => { // console.log(ctx.request) }) + + R.get('/magic/:challenge', async (ctx: Context) => { + const { challenge } = ((ctx.params: any): { challenge: string }) + const chall = await $.auth.fetchDMChallenge({ magic: challenge }) + if (chall == null) { + log.warn('bad magic', challenge) + ctx.status = 404 + return + } + + ctx.session = { + userId: chall.userId, + authType: 'dm', + expiresAt: Date.now() + 1000 * 60 * 60 * 24 + } + + log.info('logged in via magic', chall) + + return ctx.redirect('/') + }) } diff --git a/logger.js b/logger.js index 4e2f397..9d5ebb5 100644 --- a/logger.js +++ b/logger.js @@ -4,10 +4,12 @@ import chalk from 'chalk' export class Logger { debugOn: boolean name: string + quietSql: boolean constructor (name: string, debugOverride: boolean = false) { this.name = name this.debugOn = (process.env.DEBUG === 'true' || process.env.DEBUG === '*') || debugOverride + this.quietSql = (process.env.DEBUG_SQL !== 'true') } fatal (text: string, ...data: any) { @@ -52,7 +54,7 @@ export class Logger { } sql (logger: Logger, ...data: any) { - if (logger.debugOn) { + if (logger.debugOn && !logger.quietSql) { console.log(chalk.bold('DEBUG SQL:\n '), data) } } diff --git a/models/AuthChallenge.js b/models/AuthChallenge.js new file mode 100644 index 0000000..e717f2c --- /dev/null +++ b/models/AuthChallenge.js @@ -0,0 +1,14 @@ +// @flow +import type Sequelize, { DataTypes as DT } from 'sequelize' + +export default (sql: Sequelize, DataTypes: DT) => { + return sql.define('auth_challenge', { + userId: DataTypes.TEXT, + issuedAt: DataTypes.DATE, + type: DataTypes.ENUM('dm', 'other'), + human: { type: DataTypes.TEXT, unique: true }, + magic: { type: DataTypes.TEXT, unique: true } + }, { + indexes: [ { fields: ['userId'] } ] + }) +} diff --git a/services/auth.js b/services/auth.js new file mode 100644 index 0000000..84d23a7 --- /dev/null +++ b/services/auth.js @@ -0,0 +1,51 @@ +// @flow +import Service from './Service' +import nanoid from 'nanoid' +import moniker from 'moniker' +import type { AppContext } from '../Roleypoly' +// import type { Models } from '../models' + +export type DMChallenge = { + userId: string, // snowflake of the user + human: string, // humanized string for input elsewhere, adjective adjective noun + magic: string, // magic URL, a nanoid + issuedAt: Date +} + +export default class AuthService extends Service { + M: { AuthChallenge: any } + monikerGen = moniker.generator([ moniker.adjective, moniker.adjective, moniker.noun ], { glue: ' ' }) + constructor (ctx: AppContext) { + super(ctx) + this.M = ctx.M + } + + async createDMChallenge (userId: string): Promise { + const out: DMChallenge = { + userId, + human: this.monikerGen.choose(), + magic: nanoid(10), + issuedAt: new Date() + } + + await this.M.AuthChallenge.build({ ...out, type: 'dm' }).save() + this.log.debug('created DM auth challenge', out) + return out + } + + async fetchDMChallenge (input: { human: string } | { magic: string }): Promise { + const challenge: ?DMChallenge = this.M.AuthChallenge.findOne({ where: input }) + if (challenge == null) { + this.log.debug('challenge not found', challenge) + return null + } + + // if issued more than 1 hour ago, it doesn't matter. + if (+challenge.issuedAt + 3.6e5 < new Date()) { + this.log.debug('challenge expired', challenge) + return null + } + + return challenge + } +} diff --git a/services/discord.js b/services/discord.js index bd0f0cf..b2bdee6 100644 --- a/services/discord.js +++ b/services/discord.js @@ -9,6 +9,7 @@ import { type Role, type GuildMember, type Collection, + type User, Client } from 'discord.js' @@ -172,6 +173,20 @@ class DiscordService extends Service { } } + getUserPartial (userId: string): ?UserPartial { + const U = this.client.users.get(userId) + if (U == null) { + return null + } + + return { + username: U.username, + discriminator: U.discriminator, + avatar: U.displayAvatarURL, + id: U.id + } + } + // on sign out, we revoke the token we had. // async revokeAuthToken (code, state) { // const url = 'https://discordapp.com/api/oauth2/revoke' @@ -269,11 +284,54 @@ class DiscordService extends Service { this.mentionResponse(message) } + async issueChallenge (message: Message) { + // Create a challenge + const chall = await this.ctx.auth.createDMChallenge(message.author.id) + + const randomLines = [ + '🐄 A yellow cow is only as bright as it lets itself be. ✨', + '‼ **Did you know?** On this day, at least one second ago, you were right here!', + '<:AkkoC8:437428070849314816> *Reticulating splines...*', + 'Also, you look great today <:YumekoWink:439519270376964107>', + 'btw, ur bright like a <:diamond:544665968631087125>', + `🌈 psst! pssssst! I'm an expensive bot, would you please spare some change? `, + '📣 have suggestions? wanna help out? join my discord! \n*(we\'re nice people, i swear!)*', + `🤖 this bot is at least ${Math.random() * 100}% LIT 🔥`, + '💖 wanna contribute to these witty lines? suggest them on our discord!', + '🛠 I am completely open source, check me out!~ ' + ] + + message.channel.send([ + '**Hey there!** ', + '', + `Use this secret code: **${chall.human}**`, + `Or, click here: ${this.ctx.config.appUrl}/magic/${chall.magic}`, + '', + 'This code will self-destruct in 1 hour.', + '---', + randomLines[Math.floor(Math.random() * randomLines.length)] + ].join('\n')) + } + + handleDM (message: Message) { + switch (message.content.toLowerCase()) { + case 'login': + case 'auth': + case 'log in': + this.issueChallenge(message) + } + } + handleMessage (message: Message) { - if (message.author.bot || message.channel.type !== 'text') { // drop bot messages and dms + if (message.author.bot) { // drop bot messages return } + if (message.channel.type === 'dm') { + // handle dm + return this.handleDM(message) + } + if (message.mentions.users.has(this.client.user.id)) { if (this.rootUsers.has(message.author.id)) { this.handleCommand(message)