mirror of
https://github.com/roleypoly/roleypoly-v1.git
synced 2025-04-28 21:49:10 +00:00
backend/auth: redo OAuth flow and "finish" DM auth flow
This commit is contained in:
parent
1b8d90b24b
commit
ff67bc3f1b
4 changed files with 167 additions and 12 deletions
62
api/auth.js
62
api/auth.js
|
@ -24,7 +24,7 @@ export default (R: Router, $: AppContext) => {
|
||||||
ctx.session.expiresAt = new Date() + ctx.expires_in
|
ctx.session.expiresAt = new Date() + ctx.expires_in
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await $.discord.getUser(ctx.session.accessToken)
|
const user = await $.discord.getUserFromToken(ctx.session.accessToken)
|
||||||
ctx.session.userId = user.id
|
ctx.session.userId = user.id
|
||||||
ctx.session.avatarHash = user.avatar
|
ctx.session.avatarHash = user.avatar
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ export default (R: Router, $: AppContext) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await $.discord.getUser(accessToken)
|
const user = await $.discord.getUserFromToken(accessToken)
|
||||||
ctx.session.userId = user.id
|
ctx.session.userId = user.id
|
||||||
ctx.session.avatarHash = user.avatar
|
ctx.session.avatarHash = user.avatar
|
||||||
|
|
||||||
|
@ -57,6 +57,11 @@ export default (R: Router, $: AppContext) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
R.get('/api/auth/redirect', async (ctx: Context) => {
|
R.get('/api/auth/redirect', async (ctx: Context) => {
|
||||||
|
// check if already authed
|
||||||
|
if (await $.auth.isLoggedIn(ctx, { refresh: true })) {
|
||||||
|
return ctx.redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
const url = $.discord.getAuthUrl(ksuid.randomSync().string)
|
const url = $.discord.getAuthUrl(ksuid.randomSync().string)
|
||||||
if (ctx.query.url === '✔️') {
|
if (ctx.query.url === '✔️') {
|
||||||
ctx.body = { url }
|
ctx.body = { url }
|
||||||
|
@ -66,10 +71,52 @@ export default (R: Router, $: AppContext) => {
|
||||||
ctx.redirect(url)
|
ctx.redirect(url)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
R.get('/api/oauth/callback', async (ctx: Context) => {
|
||||||
|
if (await $.auth.isLoggedIn(ctx)) {
|
||||||
|
return ctx.redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, state } = ctx.query
|
||||||
|
if (code == null) {
|
||||||
|
ctx.status = 400
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state != null) {
|
||||||
|
const ksState = ksuid.parse(state)
|
||||||
|
const twoMinAgo = new Date() - 1000 * 60 * 2
|
||||||
|
if (ksState.date < twoMinAgo) {
|
||||||
|
ctx.status = 400
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await $.discord.getAuthToken(code)
|
||||||
|
const user = await $.discord.getUserFromToken(tokens.access_token)
|
||||||
|
$.auth.injectSessionFromOAuth(ctx, tokens, user.id)
|
||||||
|
return ctx.redirect('/')
|
||||||
|
} catch (e) {
|
||||||
|
log.error('token and auth fetch failure', e)
|
||||||
|
ctx.status = 400
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
R.post('/api/auth/logout', async (ctx: Context) => {
|
R.post('/api/auth/logout', async (ctx: Context) => {
|
||||||
ctx.session = null
|
ctx.session = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
R.get('/api/auth/logout', async (ctx: Context) => {
|
||||||
|
if (await $.auth.isLoggedIn(ctx)) {
|
||||||
|
if (ctx.session.authType === 'oauth') {
|
||||||
|
await $.discord.revokeOAuth(ctx.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.session = null
|
||||||
|
return ctx.redirect('/')
|
||||||
|
})
|
||||||
|
|
||||||
R.get('/api/oauth/bot', async (ctx: Context) => {
|
R.get('/api/oauth/bot', async (ctx: Context) => {
|
||||||
const url = $.discord.getBotJoinUrl()
|
const url = $.discord.getBotJoinUrl()
|
||||||
if (ctx.query.url === '✔️') {
|
if (ctx.query.url === '✔️') {
|
||||||
|
@ -89,16 +136,11 @@ export default (R: Router, $: AppContext) => {
|
||||||
const chall = await $.auth.fetchDMChallenge({ magic: challenge })
|
const chall = await $.auth.fetchDMChallenge({ magic: challenge })
|
||||||
if (chall == null) {
|
if (chall == null) {
|
||||||
log.warn('bad magic', challenge)
|
log.warn('bad magic', challenge)
|
||||||
ctx.status = 404
|
return ctx.redirect('/auth/expired')
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.session = {
|
|
||||||
userId: chall.userId,
|
|
||||||
authType: 'dm',
|
|
||||||
expiresAt: Date.now() + 1000 * 60 * 60 * 24
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$.auth.injectSessionFromChallenge(ctx, chall)
|
||||||
|
await $.auth.deleteDMChallenge(chall)
|
||||||
log.info('logged in via magic', chall)
|
log.info('logged in via magic', chall)
|
||||||
|
|
||||||
return ctx.redirect('/')
|
return ctx.redirect('/')
|
||||||
|
|
16
rpc/auth.js
Normal file
16
rpc/auth.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// @flow
|
||||||
|
import { type AppContext } from '../Roleypoly'
|
||||||
|
import { type Context } from 'koa'
|
||||||
|
|
||||||
|
export default ($: AppContext) => ({
|
||||||
|
async checkAuthChallenge (ctx: Context, text: string): Promise<boolean> {
|
||||||
|
const chall = await $.auth.fetchDMChallenge({ human: text })
|
||||||
|
if (chall == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
$.auth.injectSessionFromChallenge(ctx, chall)
|
||||||
|
$.auth.deleteDMChallenge(chall)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
|
@ -3,6 +3,8 @@ import Service from './Service'
|
||||||
import nanoid from 'nanoid'
|
import nanoid from 'nanoid'
|
||||||
import moniker from 'moniker'
|
import moniker from 'moniker'
|
||||||
import type { AppContext } from '../Roleypoly'
|
import type { AppContext } from '../Roleypoly'
|
||||||
|
import type { Context } from 'koa'
|
||||||
|
// import type { UserPartial } from './discord'
|
||||||
// import type { Models } from '../models'
|
// import type { Models } from '../models'
|
||||||
|
|
||||||
export type DMChallenge = {
|
export type DMChallenge = {
|
||||||
|
@ -12,6 +14,12 @@ export type DMChallenge = {
|
||||||
issuedAt: Date
|
issuedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthTokens = {
|
||||||
|
access_token: string,
|
||||||
|
refresh_token: string,
|
||||||
|
expires_in: string
|
||||||
|
}
|
||||||
|
|
||||||
export default class AuthService extends Service {
|
export default class AuthService extends Service {
|
||||||
M: { AuthChallenge: any }
|
M: { AuthChallenge: any }
|
||||||
monikerGen = moniker.generator([ moniker.adjective, moniker.adjective, moniker.noun ], { glue: ' ' })
|
monikerGen = moniker.generator([ moniker.adjective, moniker.adjective, moniker.noun ], { glue: ' ' })
|
||||||
|
@ -20,6 +28,26 @@ export default class AuthService extends Service {
|
||||||
this.M = ctx.M
|
this.M = ctx.M
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isLoggedIn (ctx: Context, { refresh = false }: { refresh: boolean } = {}) {
|
||||||
|
const { userId, expiresAt, authType } = ctx.session
|
||||||
|
if (userId == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAt < Date.now()) {
|
||||||
|
if (refresh && authType === 'oauth') {
|
||||||
|
const tokens = await this.ctx.discord.refreshOAuth(ctx.session)
|
||||||
|
this.injectSessionFromOAuth(ctx, tokens, userId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.session = null // reset session as well
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async createDMChallenge (userId: string): Promise<DMChallenge> {
|
async createDMChallenge (userId: string): Promise<DMChallenge> {
|
||||||
const out: DMChallenge = {
|
const out: DMChallenge = {
|
||||||
userId,
|
userId,
|
||||||
|
@ -48,4 +76,27 @@ export default class AuthService extends Service {
|
||||||
|
|
||||||
return challenge
|
return challenge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteDMChallenge (input: DMChallenge) {
|
||||||
|
return this.M.AuthChallenge.destroy({ where: { magic: input.magic } })
|
||||||
|
}
|
||||||
|
|
||||||
|
injectSessionFromChallenge (ctx: Context, chall: DMChallenge) {
|
||||||
|
ctx.session = {
|
||||||
|
userId: chall.userId,
|
||||||
|
authType: 'dm',
|
||||||
|
expiresAt: Date.now() + 1000 * 60 * 60 * 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
injectSessionFromOAuth (ctx: Context, tokens: AuthTokens, userId: string) {
|
||||||
|
const { expires_in: expiresIn, access_token: accessToken, refresh_token: refreshToken } = tokens
|
||||||
|
ctx.session = {
|
||||||
|
userId,
|
||||||
|
authType: 'oauth',
|
||||||
|
expiresAt: Date.now() + expiresIn,
|
||||||
|
accessToken,
|
||||||
|
refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
type Collection,
|
type Collection,
|
||||||
Client
|
Client
|
||||||
} from 'discord.js'
|
} from 'discord.js'
|
||||||
|
import type { AuthTokens } from './auth'
|
||||||
|
|
||||||
export type UserPartial = {
|
export type UserPartial = {
|
||||||
id: string,
|
id: string,
|
||||||
|
@ -46,6 +47,7 @@ class DiscordService extends Service {
|
||||||
super(ctx)
|
super(ctx)
|
||||||
this.appUrl = ctx.config.appUrl
|
this.appUrl = ctx.config.appUrl
|
||||||
|
|
||||||
|
this.oauthCallback = `${this.appUrl}/api/oauth/callback`
|
||||||
this.botCallback = `${this.appUrl}/api/oauth/bot/callback`
|
this.botCallback = `${this.appUrl}/api/oauth/bot/callback`
|
||||||
this.rootUsers = new Set((process.env.ROOT_USERS || '').split(','))
|
this.rootUsers = new Set((process.env.ROOT_USERS || '').split(','))
|
||||||
|
|
||||||
|
@ -132,7 +134,7 @@ class DiscordService extends Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
// oauth step 2 flow, grab the auth token via code
|
// oauth step 2 flow, grab the auth token via code
|
||||||
async getAuthToken (code: string) {
|
async getAuthToken (code: string): Promise<AuthTokens> {
|
||||||
const url = 'https://discordapp.com/api/oauth2/token'
|
const url = 'https://discordapp.com/api/oauth2/token'
|
||||||
try {
|
try {
|
||||||
const rsp =
|
const rsp =
|
||||||
|
@ -154,7 +156,7 @@ class DiscordService extends Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUser (authToken?: string): Promise<UserPartial> {
|
async getUserFromToken (authToken?: string): Promise<UserPartial> {
|
||||||
const url = 'https://discordapp.com/api/v6/users/@me'
|
const url = 'https://discordapp.com/api/v6/users/@me'
|
||||||
try {
|
try {
|
||||||
if (authToken == null || authToken === '') {
|
if (authToken == null || authToken === '') {
|
||||||
|
@ -186,6 +188,50 @@ class DiscordService extends Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshOAuth ({ refreshToken }: { refreshToken: string }): Promise<AuthTokens> {
|
||||||
|
const url = 'https://discordapp.com/api/oauth2/token'
|
||||||
|
try {
|
||||||
|
const rsp =
|
||||||
|
await superagent
|
||||||
|
.post(url)
|
||||||
|
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||||
|
.send({
|
||||||
|
client_id: this.clientId,
|
||||||
|
client_secret: this.clientSecret,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
redirect_uri: this.oauthCallback
|
||||||
|
})
|
||||||
|
|
||||||
|
return rsp.body
|
||||||
|
} catch (e) {
|
||||||
|
this.log.error('refreshOAuth failed', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeOAuth ({ accessToken }: { accessToken: string }) {
|
||||||
|
const url = 'https://discordapp.com/api/oauth2/token/revoke'
|
||||||
|
try {
|
||||||
|
const rsp =
|
||||||
|
await superagent
|
||||||
|
.post(url)
|
||||||
|
.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||||
|
.send({
|
||||||
|
client_id: this.clientId,
|
||||||
|
client_secret: this.clientSecret,
|
||||||
|
grant_type: 'access_token',
|
||||||
|
token: accessToken,
|
||||||
|
redirect_uri: this.oauthCallback
|
||||||
|
})
|
||||||
|
|
||||||
|
return rsp.body
|
||||||
|
} catch (e) {
|
||||||
|
this.log.error('revokeOAuth failed', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// on sign out, we revoke the token we had.
|
// on sign out, we revoke the token we had.
|
||||||
// async revokeAuthToken (code, state) {
|
// async revokeAuthToken (code, state) {
|
||||||
// const url = 'https://discordapp.com/api/oauth2/revoke'
|
// const url = 'https://discordapp.com/api/oauth2/revoke'
|
||||||
|
|
Loading…
Add table
Reference in a new issue