diff --git a/src/authentication.ts b/src/authentication.ts index df9433a..c516e8f 100644 --- a/src/authentication.ts +++ b/src/authentication.ts @@ -1,12 +1,14 @@ import jwt from 'jsonwebtoken' import { prisma } from './prisma.js' -import { ldapClient } from './ldapClient.js' +import { LdapController } from './controllers/LdapController.js' import { UserController } from './controllers/UserController.js' const JWT_SECRET = process.env.JWT_SECRET || 'secret' export async function login(username: string, password: string) { - await ldapClient.authenticate(username, password) + const ldap = new LdapController() + + await ldap.authenticate(username, password) await UserController.importUser(username) diff --git a/src/controllers/AuthenticationController.ts b/src/controllers/AuthenticationController.ts new file mode 100644 index 0000000..804f68e --- /dev/null +++ b/src/controllers/AuthenticationController.ts @@ -0,0 +1 @@ +export class LoginController {} diff --git a/src/controllers/LdapController.ts b/src/controllers/LdapController.ts new file mode 100644 index 0000000..207baa0 --- /dev/null +++ b/src/controllers/LdapController.ts @@ -0,0 +1,85 @@ +import { Client } from 'ldapts' + +const DOMAIN = process.env.AD_DOMAIN || 'IFMS' +const DN = process.env.AD_DN || 'DC=ifms,DC=edu,DC=br' +const BIND_USER = process.env.AD_BIND_USER || '' +const BIND_PASSWD = process.env.AD_BIND_PASSWORD || '' + +interface LdapClientInterface extends Client { + authenticate(username: string, password: string): Promise + getUser(username: string): Promise +} + +type LdapUser = { + username: string + mail: string | null + displayName: string + thumbnailPhoto: string | null +} + +export class LdapController extends Client implements LdapClientInterface { + private static instance: LdapController + + constructor() { + if (LdapController.instance) return LdapController.instance + + super({ + url: `ldap://${process.env.AD_HOST}` + }) + + LdapController.instance = this + } + + /** + * Execute a function using the ldap admin credentials. + */ + private async adminBondOperation(cb: () => Promise) { + try { + await this.bind(`${DOMAIN}\\${BIND_USER}`, BIND_PASSWD) + + return await cb() + } catch (error: any) { + throw new Error(`Error doing an Admin-bonded LDAP operation. ${error}`) + } finally { + await this.unbind() + } + } + + async getUser(username: string) { + return await this.adminBondOperation(async () => { + const { searchEntries } = await this.search(DN, { + scope: 'sub', + filter: `(sAMAccountName=${username})`, + attributes: ['mail', 'sAMAccountName', 'displayName', 'thumbnailPhoto'], + explicitBufferAttributes: ['thumbnailPhoto'] + }) + + if (!searchEntries.length) + throw new Error('User not found on LDAP server.') + + const { sAMAccountName, displayName, mail, thumbnailPhoto } = + searchEntries[0] + + const ldapUser: LdapUser = { + username: sAMAccountName.toString(), + displayName: displayName.toString(), + mail: mail.toString(), + thumbnailPhoto: `data:image/png;base64,${Buffer.from( + thumbnailPhoto as Buffer + ).toString('base64')}` + } + + return ldapUser + }) + } + + async authenticate(username: string, password: string) { + await this.bind(`${DOMAIN}\\${username}`, password) + + const user = await this.getUser(username) + + await this.unbind() + + return user + } +} diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts index 91ff117..aedd3e7 100644 --- a/src/controllers/UserController.ts +++ b/src/controllers/UserController.ts @@ -1,9 +1,13 @@ -import { ldapClient } from '../ldapClient.js' +import { LdapController } from '../controllers/LdapController.js' import { prisma } from '../prisma.js' export class UserController { static async importUser(username: string) { - const user = await ldapClient.getUser(username) + const ldap = new LdapController() + + const user = await ldap.getUser(username) + + if (!user) throw new Error('User not found!') return await prisma.user.upsert({ where: { username: user.username }, @@ -11,19 +15,4 @@ export class UserController { create: user }) } - - static async getUser(username: string) { - const user = await prisma.user.findUnique({ - where: { username } - }) - - try { - if (!user) return await UserController.importUser(username) - else UserController.importUser(username) - - return user - } catch (error: any) { - throw new Error('User not found!' + error.message) - } - } } diff --git a/src/controllers/UserRouteController.ts b/src/controllers/UserRouteController.ts new file mode 100644 index 0000000..e1149ea --- /dev/null +++ b/src/controllers/UserRouteController.ts @@ -0,0 +1,24 @@ +import { UserController } from './UserController.js' +import { prisma } from '../prisma.js' +import { Request, Response } from 'express' + +export class UserRouteController { + static async get(req: Request, res: Response) { + const { username } = req.params + + if (!username) return res.status(400).json({ error: 'Missing username' }) + + try { + const user = await prisma.user.findUnique({ + where: { username } + }) + + if (!user) return await UserController.importUser(username) + else UserController.importUser(username) + + res.json(user) + } catch (error: any) { + res.status(500).json({ error: error.message }) + } + } +} diff --git a/src/index.ts b/src/index.ts index ea11691..b535d40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,15 +2,14 @@ import 'dotenv/config' import express, { Request, Response } from 'express' import bodyParser from 'body-parser' -import { - authenticatedMiddleware, - hasRolesMiddleware -} from './middleware/authorization.js' -import { injectUserMiddleware } from './middleware/injectUser.js' +import { injectUserMiddleware } from './middleware/injectUserMiddleware.js' +import { authMiddleware } from './middleware/authMiddleware.js' +import { hasRolesMiddleware } from './middleware/hasRolesMiddleware.js' import { RequestWithUser } from './types.js' import { login } from './authentication.js' -import { UserController } from './controllers/UserController.js' + +import { UserRouteController } from './controllers/UserRouteController.js' const app = express() @@ -44,15 +43,15 @@ app.post('/api/login', async (req: Request, res: Response) => { app.get( '/api/me', - authenticatedMiddleware, + authMiddleware, async (req: RequestWithUser, res: Response) => res.json(req.user) ) app.get( '/api/protected', - authenticatedMiddleware, + authMiddleware, async (req: RequestWithUser, res: Response) => { - res.send('Hello protected world! ' + req.user?.displayName) + res.json('Hello protected world! ' + req.user?.displayName) } ) @@ -60,18 +59,14 @@ app.get( '/api/admin', await hasRolesMiddleware(['ADMIN']), async (req: RequestWithUser, res: Response) => { - res.send('Hello Admin!' + req.user?.username) + res.json('Hello Admin!' + req.user?.username) } ) app.get( '/api/user/:username', await hasRolesMiddleware(['ADMIN']), - async (req: Request, res: Response) => { - const { username } = req.params - - res.json(await UserController.getUser(username)) - } + UserRouteController.get ) // Start server diff --git a/src/ldapClient.ts b/src/ldapClient.ts deleted file mode 100644 index 60f089f..0000000 --- a/src/ldapClient.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Client } from 'ldapts' - -const DOMAIN = process.env.AD_DOMAIN || 'IFMS' -const DN = process.env.AD_DN || 'DC=ifms,DC=edu,DC=br' -const BIND_USER = process.env.AD_BIND_USER || '' -const BIND_PASSWD = process.env.AD_BIND_PASSWORD || '' - -interface LdapClient extends Client { - adminBind: () => Promise - getUser(username: string): Promise - authenticate(username: string, password: string): Promise -} - -export const ldapClient = new Client({ - url: `ldap://${process.env.AD_HOST}` -}) as LdapClient - -ldapClient.adminBind = async () => { - await ldapClient.bind(`${DOMAIN}\\${BIND_USER}`, BIND_PASSWD) -} - -ldapClient.getUser = async (username: string) => { - await ldapClient.adminBind() - - const { searchEntries } = await ldapClient.search(DN, { - scope: 'sub', - filter: `(sAMAccountName=${username})`, - attributes: [ - 'cn', - 'mail', - 'sAMAccountName', - 'displayName', - 'thumbnailPhoto' - ], - explicitBufferAttributes: ['thumbnailPhoto'] - }) - - await ldapClient.unbind() - - const { sAMAccountName, displayName, mail, thumbnailPhoto } = searchEntries[0] - - const user = { - username: sAMAccountName.toString(), - displayName: displayName.toString(), - mail: mail.toString(), - thumbnailPhoto: `data:image/png;base64,${Buffer.from( - thumbnailPhoto as Buffer - ).toString('base64')}` - } - - return user -} - -ldapClient.authenticate = async (username: string, password: string) => { - try { - await ldapClient.bind(`${DOMAIN}\\${username}`, password) - } catch (error: any) { - if (error.code === 49) { - throw new Error('Invalid username or password') - } - throw error - } finally { - await ldapClient.unbind() - } -} diff --git a/src/middleware/authMiddleware.ts b/src/middleware/authMiddleware.ts new file mode 100644 index 0000000..629ef6e --- /dev/null +++ b/src/middleware/authMiddleware.ts @@ -0,0 +1,19 @@ +import { Response, NextFunction } from 'express' +import { RequestWithUser } from '../types.js' + +export async function authMiddleware( + req: RequestWithUser, + res: Response, + next: NextFunction +) { + try { + if (!req.user) { + res.status(401).json({ error: 'Must be logged in' }) + return + } + + next() + } catch (error: any) { + res.status(401).json({ error: error.message }) + } +} diff --git a/src/middleware/authorization.ts b/src/middleware/authorization.ts deleted file mode 100644 index ca44f95..0000000 --- a/src/middleware/authorization.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Request, Response, NextFunction } from 'express' -import { authenticate } from '../authentication.js' -import { RequestWithUser } from '../types.js' -import { Role } from '@prisma/client' - -export async function authenticatedMiddleware( - req: RequestWithUser, - res: Response, - next: NextFunction -) { - try { - if (!req.user) { - res.status(401).json({ error: 'Must be logged in' }) - return - } - - next() - } catch (error: any) { - res.status(401).json({ error: error.message }) - } -} - -export async function hasRolesMiddleware(roles: Role[]) { - return async function ( - req: RequestWithUser, - res: Response, - next: NextFunction - ) { - try { - const userRoles = req.user?.roles - - if (userRoles === undefined) { - throw new Error('User has no roles') - } - - if (roles.some(role => userRoles.includes(role))) next() - else res.status(401).json({ error: 'Not authorized!' }) - } catch (error: any) { - res.status(401).json({ error: error.message }) - } - } -} diff --git a/src/middleware/hasRolesMiddleware.ts b/src/middleware/hasRolesMiddleware.ts new file mode 100644 index 0000000..94dbd2f --- /dev/null +++ b/src/middleware/hasRolesMiddleware.ts @@ -0,0 +1,20 @@ +import { Response, NextFunction } from 'express' +import { RequestWithUser } from '../types.js' +import { Role } from '@prisma/client' + +export async function hasRolesMiddleware(roles: Role[]) { + return async function ( + req: RequestWithUser, + res: Response, + next: NextFunction + ) { + try { + const userRoles = req.user?.roles + + if (roles.some(role => userRoles?.includes(role))) next() + else res.status(401).json({ error: 'Not authorized!' }) + } catch (error: any) { + res.status(401).json({ error: error.message }) + } + } +} diff --git a/src/middleware/injectUser.ts b/src/middleware/injectUserMiddleware.ts similarity index 100% rename from src/middleware/injectUser.ts rename to src/middleware/injectUserMiddleware.ts