import * as jwt from 'jsonwebtoken'; import User from '../sharedByClients/models/User.js'; import LocalConfigKeys from '../types/LocalConfigKeys.js'; import axios from 'axios'; import HttpManager from './HttpManager.js'; import ora from 'ora'; import Permissions from '../types/Permissions.js'; import ApiRoute from '../sharedByClients/types/Dojo/ApiRoute.js'; import DojoBackendManager from './DojoBackendManager.js'; import Config from '../config/Config.js'; import ClientsSharedConfig from '../sharedByClients/config/ClientsSharedConfig.js'; import DojoGitlabCredentials from '../sharedByClients/types/Dojo/DojoGitlabCredentials.js'; import * as http from 'http'; import EventEmitter from 'events'; import SharedConfig from '../shared/config/SharedConfig.js'; import chalk from 'chalk'; import inquirer from 'inquirer'; import GitlabToken from '../shared/types/Gitlab/GitlabToken.js'; import open from 'open'; import { sessionConfigFile } from '../config/ConfigFiles.js'; import TextStyle from '../types/TextStyle.js'; import DojoBackendHelper from '../sharedByClients/helpers/Dojo/DojoBackendHelper.js'; import GitlabManager from './GitlabManager.js'; import { StatusCodes } from 'http-status-codes'; class LoginServer { readonly events: EventEmitter = new EventEmitter(); private readonly server: http.Server; constructor() { this.server = http.createServer((req, res) => { const sendError = (error: string) => { this.events.emit('error', error); res.writeHead(StatusCodes.INTERNAL_SERVER_ERROR, { 'Content-Type': 'text/html' }); res.write(`<html lang="en"><body><h1 style="color: red">DojoCLI login error</h1><h3>Please look at your CLI for more informations.</h3></body></html>`); res.end(); }; if ( req.url?.match(Config.login.server.route) ) { const urlParts = req.url.split('='); if ( urlParts.length > 0 ) { res.writeHead(StatusCodes.OK, { 'Content-Type': 'text/html' }); res.write(`<html lang="en"><body><h1 style="color: green">DojoCLI login successful</h1><h3>You can close this window.</h3></body></html>`); res.end(); this.events.emit('code', urlParts[1]); return; } sendError(`Incorrect call => ${ req.url }`); } }); } start() { try { this.server.listen(Config.login.server.port); this.events.emit('started'); } catch ( error ) { this.events.emit('error', error); } } stop() { try { this.server.close(); this.server.closeAllConnections(); this.events.emit('stopped'); } catch ( error ) { this.events.emit('error', error); } } } class SessionManager { public profile: User | undefined = undefined; get isLogged(): boolean { return this.apiToken !== null && this.apiToken !== ''; } get apiToken(): string { const apisToken = sessionConfigFile.getParam(LocalConfigKeys.APIS_TOKEN) as null | { [key: string]: string }; if ( apisToken !== null && ClientsSharedConfig.apiURL in apisToken ) { return apisToken[ClientsSharedConfig.apiURL]; } return ''; } set apiToken(token: string) { let apisToken = sessionConfigFile.getParam(LocalConfigKeys.APIS_TOKEN) as null | { [key: string]: string }; if ( apisToken === null ) { apisToken = {}; } apisToken[ClientsSharedConfig.apiURL] = token; sessionConfigFile.setParam(LocalConfigKeys.APIS_TOKEN, apisToken); try { const payload = jwt.decode(token); if ( payload && typeof payload === 'object' && payload.profile ) { this.profile = payload.profile as User; } } catch ( error ) { this.profile = undefined; } } get gitlabCredentials(): DojoGitlabCredentials { return sessionConfigFile.getParam(LocalConfigKeys.GITLAB) as DojoGitlabCredentials; } set gitlabCredentials(credentials: DojoGitlabCredentials) { sessionConfigFile.setParam(LocalConfigKeys.GITLAB, credentials); if ( credentials.accessToken ) { GitlabManager.setToken(credentials.accessToken); } } private async getGitlabCodeFromHeadlessEnvironment(): Promise<string> { const indent: string = ' '; console.log(`${ indent }Please open the following URL in your web browser and accept to give the requested permissions to Dojo:`); console.log(TextStyle.URL(`${ indent }${ Config.login.gitlab.url.code }`)); console.log(`${ indent }Then, copy the code at the end of the redirected url and paste it bellow.`); console.log(`${ indent }Example of url (here the code is 123456): ${ TextStyle.URL(SharedConfig.login.gitlab.url.redirect + '?code=') }${ chalk.green('123456') }`); return (await inquirer.prompt({ type : 'password', name : 'code', message: `${ chalk.green('?') } Please paste the Gitlab code here`, mask : '*', prefix : ' ' })).code; } private getGitlabCodeFromGraphicEnvironment(): Promise<string> { return new Promise<string>((resolve, reject) => { ora({ text : 'GUI mode (for headless mode, use the --cli option)', indent: 4 }).start().info(); let currentSpinner: ora.Ora = ora({ text : 'Starting login server', indent: 4 }).start(); const loginServer = new LoginServer(); loginServer.events.on('started', () => { currentSpinner.succeed('Login server started'); currentSpinner = ora({ text : `Waiting for user to authorize the application in his web browser. If the browser does not open automatically, please go to : ${ Config.login.gitlab.url.code }`, indent: 4 }).start(); open(Config.login.gitlab.url.code).then(); }); loginServer.events.on('error', (error: string) => { currentSpinner.fail(`Login server error: ${ error }`); reject(); }); loginServer.events.on('stopped', () => { currentSpinner.succeed('Login server stopped'); }); loginServer.events.on('code', (code: string) => { currentSpinner.succeed('Login code received'); currentSpinner = ora({ text : 'Stopping login server', indent: 4 }).start(); loginServer.events.on('stopped', () => { resolve(code); }); loginServer.stop(); resolve(code); }); loginServer.start(); }); } async login(headless: boolean = false) { try { this.logout(); } catch ( error ) { console.log(error); ora('Unknown error').start().fail(); throw error; } ora(`Login with Gitlab (${ SharedConfig.gitlab.URL }):`).start().info(); let gitlabCode: string; if ( !headless ) { gitlabCode = await this.getGitlabCodeFromGraphicEnvironment(); } else { gitlabCode = await this.getGitlabCodeFromHeadlessEnvironment(); } const gitlabTokensSpinner = ora({ text : 'Retrieving gitlab tokens', indent: 4 }).start(); let gitlabTokens: GitlabToken; try { gitlabTokens = await GitlabManager.getTokens(gitlabCode); this.gitlabCredentials = { refreshToken: gitlabTokens.refresh_token, accessToken : gitlabTokens.access_token }; gitlabTokensSpinner.succeed('Gitlab tokens retrieved'); } catch ( error ) { gitlabTokensSpinner.fail('Error while retrieving gitlab tokens'); throw error; } const isGitlabTokensValid = (await GitlabManager.testToken()).every(value => value); if ( !isGitlabTokensValid ) { throw new Error('Gitlab tokens are invalid'); } ora(`Login to Dojo backend:`).start().info(); const dojoLoginSpinner = ora({ text : 'Login to Dojo backend', indent: 4 }).start(); try { await DojoBackendManager.login(gitlabTokens); dojoLoginSpinner.succeed('Logged in'); } catch ( error ) { dojoLoginSpinner.fail('Login failed'); throw error; } await this.testSession(true); } async refreshTokens() { const gitlabTokens = await DojoBackendManager.refreshTokens(this.gitlabCredentials.refreshToken!); this.gitlabCredentials = { refreshToken: gitlabTokens.refresh_token, accessToken : gitlabTokens.access_token }; } logout() { this.apiToken = ''; this.gitlabCredentials = { refreshToken: '', accessToken : '' }; } private hasPermission(permissionPredicate: () => boolean, verbose: boolean, verboseText: string, indent: number): boolean { const isAllowed: boolean = this.profile !== undefined && permissionPredicate(); if ( verbose ) { const spinner: ora.Ora = ora({ text : verboseText, indent: indent }).start(); isAllowed ? spinner.succeed() : spinner.fail(); } return isAllowed; } checkPermissions(verbose: boolean = true, indent: number = 8, checkPermissions: Array<string> | null = []): Permissions { return { student : checkPermissions && (checkPermissions.length === 0 || checkPermissions.includes('student')) ? this.hasPermission(() => true, verbose, 'Student permissions', indent) : false, teachingStaff: checkPermissions && (checkPermissions.length === 0 || checkPermissions.includes('teachingStaff')) ? this.hasPermission(() => this.profile?.isTeachingStaff ?? false, verbose, 'Teaching staff permissions', indent) : false, admin : checkPermissions && (checkPermissions.length === 0 || checkPermissions.includes('admin')) ? this.hasPermission(() => this.profile?.isAdmin ?? false, verbose, 'Admin permissions', indent) : false }; } async testSession(verbose: boolean = true, checkPermissions: Array<string> | null = []): Promise<false | Permissions> { if ( verbose ) { ora('Checking Dojo session: ').start().info(); } HttpManager.handleAuthorizationCommandErrors = false; const spinner: ora.Ora = ora({ text : `Testing Dojo session`, indent: 4 }); if ( verbose ) { spinner.start(); } try { await axios.get(DojoBackendHelper.getApiUrl(ApiRoute.TEST_SESSION), {}); if ( verbose ) { spinner.succeed(`The session is valid`); } } catch ( error ) { if ( verbose ) { spinner.fail(`The session is invalid`); } return false; } if ( checkPermissions && checkPermissions.length === 0 ) { ora({ text : `Here is your permissions:`, indent: 4 }).start().info(); } return this.checkPermissions(verbose, 8, checkPermissions); } } export default new SessionManager();