339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
const passport = require('passport');
|
|
const OpenIDConnectStrategy = require('passport-openidconnect');
|
|
|
|
const express = require('express');
|
|
const { param } = require('./applications');
|
|
const router = express.Router();
|
|
import { Role } from '@app/shared/types/roles';
|
|
import pool from '../db';
|
|
import { requireLogin } from '../middleware/auth';
|
|
import { getUserRoles } from '../services/db/rolesService';
|
|
import { getUserState, mapDiscordtoID } from '../services/db/memberService';
|
|
import { MemberState } from '@app/shared/types/member';
|
|
import { toDateTime } from '@app/shared/utils/time';
|
|
import { logger } from '../services/logging/logger';
|
|
const querystring = require('querystring');
|
|
import { performance } from 'perf_hooks';
|
|
import { CacheService } from '../services/cache/cache';
|
|
import { Strategy as CustomStrategy } from 'passport-custom';
|
|
|
|
|
|
function parseJwt(token) {
|
|
return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
}
|
|
|
|
const devLogin = (req: any, res: any, next: any) => {
|
|
// The object here must match what your 'verify' function returns: { memberId }
|
|
const devUser = { memberId: 1 }; // Hardcoded ID
|
|
|
|
req.logIn(devUser, (err: any) => {
|
|
if (err) return next(err);
|
|
const redirectTo = req.session.redirectTo || process.env.CLIENT_URL;
|
|
delete req.session.redirectTo;
|
|
return res.redirect(redirectTo);
|
|
});
|
|
};
|
|
|
|
if (process.env.AUTH_MODE === "mock") {
|
|
passport.use('mock', new CustomStrategy(async (req, done) => {
|
|
const mockUser = { memberId: 1 };
|
|
return done(null, mockUser);
|
|
}))
|
|
} else {
|
|
passport.use('oidc', new OpenIDConnectStrategy({
|
|
issuer: process.env.AUTH_ISSUER,
|
|
authorizationURL: process.env.AUTH_DOMAIN + '/authorize/',
|
|
tokenURL: process.env.AUTH_DOMAIN + '/token/',
|
|
userInfoURL: process.env.AUTH_DOMAIN + '/userinfo/',
|
|
clientID: process.env.AUTH_CLIENT_ID,
|
|
clientSecret: process.env.AUTH_CLIENT_SECRET,
|
|
callbackURL: process.env.AUTH_REDIRECT_URI,
|
|
scope: ['openid', 'profile', 'discord']
|
|
}, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) {
|
|
|
|
// console.log('--- OIDC verify() called ---');
|
|
// console.log('issuer:', issuer);
|
|
// console.log('sub:', sub);
|
|
// // console.log('discord:', discord);
|
|
// console.log('profile:', profile);
|
|
// console.log('jwt: ', parseJwt(jwtClaims));
|
|
// console.log('params:', params);
|
|
let con;
|
|
|
|
try {
|
|
con = await pool.getConnection();
|
|
|
|
await con.beginTransaction();
|
|
|
|
//lookup existing user
|
|
const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]);
|
|
let memberId: number | null = null;
|
|
//if member exists
|
|
if (existing.length > 0) {
|
|
//login
|
|
memberId = existing[0].id;
|
|
logger.info('auth', `Existing member login`, {
|
|
memberId,
|
|
issuer,
|
|
});
|
|
|
|
} else {
|
|
//otherwise: create account mode
|
|
const jwt = parseJwt(jwtClaims);
|
|
const discordID = jwt.discord?.id as number;
|
|
|
|
//check if account is available to claim
|
|
if (discordID)
|
|
memberId = await mapDiscordtoID(discordID);
|
|
|
|
if (discordID && memberId) {
|
|
// claim account
|
|
const result = await con.query(
|
|
`UPDATE members SET authentik_sub = ?, authentik_issuer = ? WHERE id = ?;`,
|
|
[sub, issuer, memberId]
|
|
)
|
|
logger.info('auth', `Existing member claimed via Discord`, {
|
|
memberId,
|
|
discordID,
|
|
issuer,
|
|
});
|
|
|
|
} else {
|
|
// new account
|
|
const username = sub.username;
|
|
const result = await con.query(
|
|
`INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`,
|
|
[username, sub, issuer]
|
|
)
|
|
memberId = Number(result.insertId);
|
|
|
|
logger.info('auth', `New member account created`, {
|
|
memberId,
|
|
username,
|
|
issuer,
|
|
});
|
|
|
|
}
|
|
}
|
|
|
|
await con.query(`UPDATE members SET last_login = ? WHERE id = ?`, [toDateTime(new Date()), memberId])
|
|
|
|
await con.commit();
|
|
return cb(null, { memberId });
|
|
} catch (error) {
|
|
logger.error('auth', `Authentication transaction failed`, {
|
|
issuer,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
|
|
if (con) {
|
|
try {
|
|
await con.rollback();
|
|
} catch (rollbackError) {
|
|
logger.error('auth', `Rollback failed`, {
|
|
error: rollbackError instanceof Error
|
|
? rollbackError.message
|
|
: String(rollbackError),
|
|
});
|
|
}
|
|
}
|
|
return cb(error);
|
|
} finally {
|
|
if (con) con.release();
|
|
}
|
|
}));
|
|
}
|
|
|
|
router.get('/login', (req, res, next) => {
|
|
req.session.redirectTo = req.query.redirect as string;
|
|
|
|
const strategy = process.env.AUTH_MODE === 'mock' ? 'mock' : 'oidc';
|
|
|
|
passport.authenticate(strategy, {
|
|
successRedirect: (req.session.redirectTo || process.env.CLIENT_URL),
|
|
failureRedirect: '/login'
|
|
})(req, res, next);
|
|
});
|
|
|
|
router.get('/callback', (req, res, next) => {
|
|
|
|
//escape if mocked
|
|
if (process.env.AUTH_MODE === 'mock') {
|
|
return res.redirect(process.env.CLIENT_URL || '/');
|
|
}
|
|
|
|
const redirectURI = req.session.redirectTo;
|
|
passport.authenticate('oidc', (err, user) => {
|
|
if (err) return next(err);
|
|
if (!user) return res.redirect(process.env.CLIENT_URL);
|
|
|
|
req.logIn(user, err => {
|
|
if (err) return next(err);
|
|
|
|
// Use redirect saved from session
|
|
const redirectTo = redirectURI || process.env.CLIENT_URL;
|
|
delete req.session.redirectTo;
|
|
return res.redirect(redirectTo);
|
|
});
|
|
})(req, res, next);
|
|
});
|
|
|
|
router.get('/logout', [requireLogin], function (req, res, next) {
|
|
req.logout(function (err) {
|
|
|
|
if (err) { return next(err); }
|
|
|
|
req.session.destroy((err) => {
|
|
if (err) { return next(err); }
|
|
|
|
res.clearCookie('connect.sid', {
|
|
path: '/',
|
|
domain: process.env.CLIENT_DOMAIN,
|
|
httpOnly: true,
|
|
sameSite: 'lax'
|
|
});
|
|
|
|
if (process.env.AUTH_MODE === 'mock') {
|
|
return res.redirect(process.env.CLIENT_URL || '/');
|
|
}
|
|
|
|
var params = {
|
|
client_id: process.env.AUTH_CLIENT_ID,
|
|
returnTo: process.env.CLIENT_URL
|
|
};
|
|
|
|
const endSessionUri = process.env.AUTH_END_SESSION_URI;
|
|
if (endSessionUri) {
|
|
return res.redirect(endSessionUri + '?' + querystring.stringify(params));
|
|
} else {
|
|
return res.redirect(process.env.CLIENT_URL || '/');
|
|
}
|
|
})
|
|
});
|
|
});
|
|
|
|
passport.serializeUser(function (user, cb) {
|
|
process.nextTick(function () {
|
|
cb(null, user);
|
|
});
|
|
});
|
|
|
|
passport.deserializeUser(function (user, cb) {
|
|
const start = performance.now();
|
|
const timings: Record<string, number> = {};
|
|
|
|
process.nextTick(async function () {
|
|
const memberID = user.memberId as number;
|
|
let con;
|
|
|
|
try {
|
|
//cache lookup
|
|
let t = performance.now();
|
|
const cachedData: UserData | undefined = userCache.Get(memberID);
|
|
timings.cache_lookup = performance.now() - t;
|
|
|
|
if (cachedData) {
|
|
timings.total = performance.now() - start;
|
|
|
|
logger.info(
|
|
'profiling',
|
|
'passport.deserializeUser (cache hit)',
|
|
{
|
|
memberId: memberID,
|
|
cache_hit: true,
|
|
source: 'cache',
|
|
total_ms: timings.total,
|
|
breakdown_ms: timings,
|
|
},
|
|
'profiling'
|
|
);
|
|
|
|
return cb(null, cachedData);
|
|
}
|
|
|
|
//cache miss, db load
|
|
t = performance.now();
|
|
con = await pool.getConnection();
|
|
timings.getConnection = performance.now() - t;
|
|
|
|
t = performance.now();
|
|
const userResults = await con.query(
|
|
`SELECT id, name, discord_id FROM members WHERE id = ?;`,
|
|
[memberID]
|
|
);
|
|
timings.memberQuery = performance.now() - t;
|
|
|
|
const userData: UserData = userResults[0];
|
|
|
|
t = performance.now();
|
|
userData.roles = await getUserRoles(memberID) || [];
|
|
timings.roles = performance.now() - t;
|
|
|
|
t = performance.now();
|
|
userData.state = await getUserState(memberID);
|
|
timings.state = performance.now() - t;
|
|
|
|
t = performance.now();
|
|
userCache.Set(userData.id, userData);
|
|
timings.cache_set = performance.now() - t;
|
|
|
|
timings.total = performance.now() - start;
|
|
|
|
logger.info(
|
|
'profiling',
|
|
'passport.deserializeUser (db load)',
|
|
{
|
|
memberId: memberID,
|
|
cache_hit: false,
|
|
source: 'db',
|
|
total_ms: timings.total,
|
|
breakdown_ms: timings,
|
|
},
|
|
'profiling'
|
|
);
|
|
|
|
return cb(null, userData);
|
|
|
|
} catch (error) {
|
|
logger.error(
|
|
'profiling',
|
|
'passport.deserializeUser failed',
|
|
{
|
|
memberId: memberID,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}
|
|
);
|
|
return cb(error);
|
|
} finally {
|
|
if (con) con.release();
|
|
}
|
|
});
|
|
});
|
|
|
|
declare global {
|
|
namespace Express {
|
|
interface Request {
|
|
user: {
|
|
id: number;
|
|
name: string;
|
|
discord_id: string;
|
|
roles: Role[];
|
|
state: MemberState;
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface UserData {
|
|
id: number;
|
|
name: string;
|
|
roles: Role[];
|
|
state: MemberState;
|
|
discord_id?: string;
|
|
}
|
|
|
|
const userCache = new CacheService<number, UserData>();
|
|
|
|
export const authRouter = router;
|
|
export const memberCache = userCache; |