Merge pull request #1251 from thecodingmachine/openIDPoc

POC for the openID connect
This commit is contained in:
Kharhamel 2021-07-30 15:48:56 +02:00 committed by GitHub
commit 2b7481383f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 866 additions and 1536 deletions

View File

@ -19,3 +19,6 @@ ACME_EMAIL=
MAX_PER_GROUP=4 MAX_PER_GROUP=4
MAX_USERNAME_LENGTH=8 MAX_USERNAME_LENGTH=8
OPID_CLIENT_ID=
OPID_CLIENT_SECRET=
OPID_CLIENT_ISSUER=

View File

@ -82,7 +82,7 @@ jobs:
- name: "Setup NodeJS" - name: "Setup NodeJS"
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: '12.x' node-version: '14.x'
- name: Install Protoc - name: Install Protoc
uses: arduino/setup-protoc@v1 uses: arduino/setup-protoc@v1

View File

@ -1,4 +1,12 @@
## Version 1.4.x-dev ## Version develop
### Updates
- Rewrote the way authentification works: the auth jwt token can now contains an email instead of an uuid
- Added an OpenId login flow than can be plugged to any OIDC provider.
-
## Version 1.4.10
### Updates ### Updates

View File

@ -13,7 +13,6 @@ RoomConnection.setWebsocketFactory((url: string) => {
}); });
async function startOneUser(): Promise<void> { async function startOneUser(): Promise<void> {
await connectionManager.anonymousLogin(true);
const onConnect = await connectionManager.connectToRoomSocket(process.env.ROOM_ID ? process.env.ROOM_ID : '_/global/maps.workadventure.localhost/Floor0/floor0.json', 'TEST', ['male3'], const onConnect = await connectionManager.connectToRoomSocket(process.env.ROOM_ID ? process.env.ROOM_ID : '_/global/maps.workadventure.localhost/Floor0/floor0.json', 'TEST', ['male3'],
{ {
x: 783, x: 783,
@ -23,7 +22,7 @@ async function startOneUser(): Promise<void> {
bottom: 200, bottom: 200,
left: 500, left: 500,
right: 800 right: 800
}); }, null);
const connection = onConnect.connection; const connection = onConnect.connection;

View File

@ -66,6 +66,10 @@ services:
API_URL: back:50051 API_URL: back:50051
JITSI_URL: $JITSI_URL JITSI_URL: $JITSI_URL
JITSI_ISS: $JITSI_ISS JITSI_ISS: $JITSI_ISS
FRONT_URL: http://localhost
OPID_CLIENT_ID: $OPID_CLIENT_ID
OPID_CLIENT_SECRET: $OPID_CLIENT_SECRET
OPID_CLIENT_ISSUER: $OPID_CLIENT_ISSUER
volumes: volumes:
- ./pusher:/usr/src/app - ./pusher:/usr/src/app
labels: labels:

View File

@ -66,6 +66,10 @@ services:
API_URL: back:50051 API_URL: back:50051
JITSI_URL: $JITSI_URL JITSI_URL: $JITSI_URL
JITSI_ISS: $JITSI_ISS JITSI_ISS: $JITSI_ISS
FRONT_URL: http://play.workadventure.localhost
OPID_CLIENT_ID: $OPID_CLIENT_ID
OPID_CLIENT_SECRET: $OPID_CLIENT_SECRET
OPID_CLIENT_ISSUER: $OPID_CLIENT_ISSUER
volumes: volumes:
- ./pusher:/usr/src/app - ./pusher:/usr/src/app
labels: labels:

View File

@ -60,6 +60,9 @@
<section> <section>
<button id="enableNotification">Enable notifications</button> <button id="enableNotification">Enable notifications</button>
</section> </section>
<section hidden>
<button id="oidcLogin">Oauth Login</button>
</section>
<section> <section>
<button id="sparkButton">Create map</button> <button id="sparkButton">Create map</button>
</section> </section>

View File

@ -10,6 +10,7 @@
"@types/mini-css-extract-plugin": "^1.4.3", "@types/mini-css-extract-plugin": "^1.4.3",
"@types/node": "^15.3.0", "@types/node": "^15.3.0",
"@types/quill": "^1.3.7", "@types/quill": "^1.3.7",
"@types/uuidv4": "^5.0.0",
"@types/webpack-dev-server": "^3.11.4", "@types/webpack-dev-server": "^3.11.4",
"@typescript-eslint/eslint-plugin": "^4.23.0", "@typescript-eslint/eslint-plugin": "^4.23.0",
"@typescript-eslint/parser": "^4.23.0", "@typescript-eslint/parser": "^4.23.0",
@ -53,7 +54,8 @@
"rxjs": "^6.6.3", "rxjs": "^6.6.3",
"simple-peer": "^9.11.0", "simple-peer": "^9.11.0",
"socket.io-client": "^2.3.0", "socket.io-client": "^2.3.0",
"standardized-audio-context": "^25.2.4" "standardized-audio-context": "^25.2.4",
"uuidv4": "^6.2.10"
}, },
"scripts": { "scripts": {
"start": "run-p templater serve svelte-check-watch", "start": "run-p templater serve svelte-check-watch",

View File

@ -14,6 +14,7 @@ class ConnectionManager {
private connexionType?: GameConnexionTypes; private connexionType?: GameConnexionTypes;
private reconnectingTimeout: NodeJS.Timeout | null = null; private reconnectingTimeout: NodeJS.Timeout | null = null;
private _unloading: boolean = false; private _unloading: boolean = false;
private authToken: string | null = null;
private serviceWorker?: _ServiceWorker; private serviceWorker?: _ServiceWorker;
@ -27,21 +28,57 @@ class ConnectionManager {
if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout); if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout);
}); });
} }
public loadOpenIDScreen() {
localUserStore.setAuthToken(null);
const state = localUserStore.generateState();
const nonce = localUserStore.generateNonce();
window.location.assign(`http://${PUSHER_URL}/login-screen?state=${state}&nonce=${nonce}`);
}
public logout() {
localUserStore.setAuthToken(null);
window.location.reload();
}
/** /**
* Tries to login to the node server and return the starting map url to be loaded * Tries to login to the node server and return the starting map url to be loaded
*/ */
public async initGameConnexion(): Promise<Room> { public async initGameConnexion(): Promise<Room> {
const connexionType = urlManager.getGameConnexionType(); const connexionType = urlManager.getGameConnexionType();
this.connexionType = connexionType; this.connexionType = connexionType;
let room: Room | null = null; let room: Room | null = null;
if (connexionType === GameConnexionTypes.register) { if (connexionType === GameConnexionTypes.jwt) {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get("code");
const state = urlParams.get("state");
if (!state || !localUserStore.verifyState(state)) {
throw "Could not validate state!";
}
if (!code) {
throw "No Auth code provided";
}
const nonce = localUserStore.getNonce();
const { authToken } = await Axios.get(`${PUSHER_URL}/login-callback`, { params: { code, nonce } }).then(
(res) => res.data
);
localUserStore.setAuthToken(authToken);
this.authToken = authToken;
room = await Room.createRoom(
new URL(localUserStore.getLastRoomUrl())
);
urlManager.pushRoomIdToUrl(room);
} else if (connexionType === GameConnexionTypes.register) {
//@deprecated
const organizationMemberToken = urlManager.getOrganizationToken(); const organizationMemberToken = urlManager.getOrganizationToken();
const data = await Axios.post(`${PUSHER_URL}/register`, { organizationMemberToken }).then( const data = await Axios.post(`${PUSHER_URL}/register`, { organizationMemberToken }).then(
(res) => res.data (res) => res.data
); );
this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures); this.localUser = new LocalUser(data.userUuid, data.textures);
this.authToken = data.authToken;
localUserStore.saveUser(this.localUser); localUserStore.saveUser(this.localUser);
localUserStore.setAuthToken(this.authToken);
const roomUrl = data.roomUrl; const roomUrl = data.roomUrl;
@ -61,24 +98,12 @@ class ConnectionManager {
connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.anonymous ||
connexionType === GameConnexionTypes.empty connexionType === GameConnexionTypes.empty
) { ) {
let localUser = localUserStore.getLocalUser(); this.authToken = localUserStore.getAuthToken();
if (localUser && localUser.jwtToken && localUser.uuid && localUser.textures) { //todo: add here some kind of warning if authToken has expired.
this.localUser = localUser; if (!this.authToken) {
try {
await this.verifyToken(localUser.jwtToken);
} catch (e) {
// If the token is invalid, let's generate an anonymous one.
console.error("JWT token invalid. Did it expire? Login anonymously instead.");
await this.anonymousLogin(); await this.anonymousLogin();
} }
} else { this.localUser = localUserStore.getLocalUser() as LocalUser; //if authToken exist in localStorage then localUser cannot be null
await this.anonymousLogin();
}
localUser = localUserStore.getLocalUser();
if (!localUser) {
throw "Error to store local user data";
}
let roomPath: string; let roomPath: string;
if (connexionType === GameConnexionTypes.empty) { if (connexionType === GameConnexionTypes.empty) {
@ -97,19 +122,18 @@ class ConnectionManager {
room = await Room.createRoom(new URL(roomPath)); room = await Room.createRoom(new URL(roomPath));
if (room.textures != undefined && room.textures.length > 0) { if (room.textures != undefined && room.textures.length > 0) {
//check if texture was changed //check if texture was changed
if (localUser.textures.length === 0) { if (this.localUser.textures.length === 0) {
localUser.textures = room.textures; this.localUser.textures = room.textures;
} else { } else {
room.textures.forEach((newTexture) => { room.textures.forEach((newTexture) => {
const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id); const alreadyExistTexture = this.localUser.textures.find((c) => newTexture.id === c.id);
if (localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1) { if (this.localUser.textures.findIndex((c) => newTexture.id === c.id) !== -1) {
return; return;
} }
localUser?.textures.push(newTexture); this.localUser.textures.push(newTexture);
}); });
} }
this.localUser = localUser; localUserStore.saveUser(this.localUser);
localUserStore.saveUser(localUser);
} }
} }
if (room == undefined) { if (room == undefined) {
@ -120,21 +144,19 @@ class ConnectionManager {
return Promise.resolve(room); return Promise.resolve(room);
} }
private async verifyToken(token: string): Promise<void> {
await Axios.get(`${PUSHER_URL}/verify`, { params: { token } });
}
public async anonymousLogin(isBenchmark: boolean = false): Promise<void> { public async anonymousLogin(isBenchmark: boolean = false): Promise<void> {
const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then((res) => res.data); const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then((res) => res.data);
this.localUser = new LocalUser(data.userUuid, data.authToken, []); this.localUser = new LocalUser(data.userUuid, []);
this.authToken = data.authToken;
if (!isBenchmark) { if (!isBenchmark) {
// In benchmark, we don't have a local storage. // In benchmark, we don't have a local storage.
localUserStore.saveUser(this.localUser); localUserStore.saveUser(this.localUser);
localUserStore.setAuthToken(this.authToken);
} }
} }
public initBenchmark(): void { public initBenchmark(): void {
this.localUser = new LocalUser("", "test", []); this.localUser = new LocalUser("", []);
} }
public connectToRoomSocket( public connectToRoomSocket(
@ -147,7 +169,7 @@ class ConnectionManager {
): Promise<OnConnectInterface> { ): Promise<OnConnectInterface> {
return new Promise<OnConnectInterface>((resolve, reject) => { return new Promise<OnConnectInterface>((resolve, reject) => {
const connection = new RoomConnection( const connection = new RoomConnection(
this.localUser.jwtToken, this.authToken,
roomUrl, roomUrl,
name, name,
characterLayers, characterLayers,

View File

@ -1,10 +1,10 @@
import { MAX_USERNAME_LENGTH } from "../Enum/EnvironmentVariable"; import { MAX_USERNAME_LENGTH } from "../Enum/EnvironmentVariable";
export interface CharacterTexture { export interface CharacterTexture {
id: number, id: number;
level: number, level: number;
url: string, url: string;
rights: string rights: string;
} }
export const maxUserNameLength: number = MAX_USERNAME_LENGTH; export const maxUserNameLength: number = MAX_USERNAME_LENGTH;
@ -24,6 +24,5 @@ export function areCharacterLayersValid(value: string[] | null): boolean {
} }
export class LocalUser { export class LocalUser {
constructor(public readonly uuid:string, public readonly jwtToken: string, public textures: CharacterTexture[]) { constructor(public readonly uuid: string, public textures: CharacterTexture[]) {}
}
} }

View File

@ -1,4 +1,5 @@
import { areCharacterLayersValid, isUserNameValid, LocalUser } from "./LocalUser"; import { areCharacterLayersValid, isUserNameValid, LocalUser } from "./LocalUser";
import { v4 as uuidv4 } from "uuid";
const playerNameKey = "playerName"; const playerNameKey = "playerName";
const selectedPlayerKey = "selectedPlayer"; const selectedPlayerKey = "selectedPlayer";
@ -12,6 +13,9 @@ const audioPlayerMuteKey = "audioMute";
const helpCameraSettingsShown = "helpCameraSettingsShown"; const helpCameraSettingsShown = "helpCameraSettingsShown";
const fullscreenKey = "fullscreen"; const fullscreenKey = "fullscreen";
const lastRoomUrl = "lastRoomUrl"; const lastRoomUrl = "lastRoomUrl";
const authToken = "authToken";
const state = "state";
const nonce = "nonce";
class LocalUserStore { class LocalUserStore {
saveUser(localUser: LocalUser) { saveUser(localUser: LocalUser) {
@ -116,6 +120,36 @@ class LocalUserStore {
getLastRoomUrl(): string { getLastRoomUrl(): string {
return localStorage.getItem(lastRoomUrl) ?? ""; return localStorage.getItem(lastRoomUrl) ?? "";
} }
setAuthToken(value: string | null) {
value ? localStorage.setItem(authToken, value) : localStorage.removeItem(authToken);
}
getAuthToken(): string | null {
return localStorage.getItem(authToken);
}
generateState(): string {
const newState = uuidv4();
localStorage.setItem(state, newState);
return newState;
}
verifyState(value: string): boolean {
const oldValue = localStorage.getItem(state);
localStorage.removeItem(state);
return oldValue === value;
}
generateNonce(): string {
const newNonce = uuidv4();
localStorage.setItem(nonce, newNonce);
return newNonce;
}
getNonce(): string | null {
const oldValue = localStorage.getItem(nonce);
localStorage.removeItem(nonce);
return oldValue;
}
} }
export const localUserStore = new LocalUserStore(); export const localUserStore = new LocalUserStore();

View File

@ -76,7 +76,7 @@ export class RoomConnection implements RoomConnection {
/** /**
* *
* @param token A JWT token containing the UUID of the user * @param token A JWT token containing the email of the user
* @param roomUrl The URL of the room in the form "https://example.com/_/[instance]/[map_url]" or "https://example.com/@/[org]/[event]/[map]" * @param roomUrl The URL of the room in the form "https://example.com/_/[instance]/[map_url]" or "https://example.com/@/[org]/[event]/[map]"
*/ */
public constructor( public constructor(
@ -217,6 +217,9 @@ export class RoomConnection implements RoomConnection {
} else if (message.hasWorldfullmessage()) { } else if (message.hasWorldfullmessage()) {
worldFullMessageStream.onMessage(); worldFullMessageStream.onMessage();
this.closed = true; this.closed = true;
} else if (message.hasTokenexpiredmessage()) {
connectionManager.loadOpenIDScreen();
this.closed = true; //technically, this isn't needed since loadOpenIDScreen() will do window.location.assign() but I prefer to leave it for consistency
} else if (message.hasWorldconnexionmessage()) { } else if (message.hasWorldconnexionmessage()) {
worldFullMessageStream.onMessage(message.getWorldconnexionmessage()?.getMessage()); worldFullMessageStream.onMessage(message.getWorldconnexionmessage()?.getMessage());
this.closed = true; this.closed = true;

View File

@ -1,6 +1,6 @@
const DEBUG_MODE: boolean = process.env.DEBUG_MODE == "true"; const DEBUG_MODE: boolean = process.env.DEBUG_MODE == "true";
const START_ROOM_URL: string = const START_ROOM_URL: string =
process.env.START_ROOM_URL || "/_/global/maps.workadventure.localhost/Floor0/floor0.json"; process.env.START_ROOM_URL || "/_/global/maps.workadventure.localhost/Floor1/floor1.json";
const PUSHER_URL = process.env.PUSHER_URL || "//pusher.workadventure.localhost"; const PUSHER_URL = process.env.PUSHER_URL || "//pusher.workadventure.localhost";
export const ADMIN_URL = process.env.ADMIN_URL || "//workadventu.re"; export const ADMIN_URL = process.env.ADMIN_URL || "//workadventu.re";
const UPLOADER_URL = process.env.UPLOADER_URL || "//uploader.workadventure.localhost"; const UPLOADER_URL = process.env.UPLOADER_URL || "//uploader.workadventure.localhost";

View File

@ -344,6 +344,9 @@ export class MenuScene extends Phaser.Scene {
case "editGameSettingsButton": case "editGameSettingsButton":
this.openGameSettingsMenu(); this.openGameSettingsMenu();
break; break;
case "oidcLogin":
connectionManager.loadOpenIDScreen();
break;
case "toggleFullscreen": case "toggleFullscreen":
this.toggleFullscreen(); this.toggleFullscreen();
break; break;

View File

@ -6,21 +6,22 @@ export enum GameConnexionTypes {
register, register,
empty, empty,
unknown, unknown,
jwt,
} }
//this class is responsible with analysing and editing the game's url //this class is responsible with analysing and editing the game's url
class UrlManager { class UrlManager {
//todo: use that to detect if we can find a token in localstorage
public getGameConnexionType(): GameConnexionTypes { public getGameConnexionType(): GameConnexionTypes {
const url = window.location.pathname.toString(); const url = window.location.pathname.toString();
if (url.includes('_/')) { if (url === "/jwt") {
return GameConnexionTypes.jwt;
} else if (url.includes("_/")) {
return GameConnexionTypes.anonymous; return GameConnexionTypes.anonymous;
} else if (url.includes('@/')) { } else if (url.includes("@/")) {
return GameConnexionTypes.organization; return GameConnexionTypes.organization;
} else if(url.includes('register/')) { } else if (url.includes("register/")) {
return GameConnexionTypes.register; return GameConnexionTypes.register;
} else if(url === '/') { } else if (url === "/") {
return GameConnexionTypes.empty; return GameConnexionTypes.empty;
} else { } else {
return GameConnexionTypes.unknown; return GameConnexionTypes.unknown;
@ -36,7 +37,7 @@ class UrlManager {
if (window.location.pathname === room.id) return; if (window.location.pathname === room.id) return;
const hash = window.location.hash; const hash = window.location.hash;
const search = room.search.toString(); const search = room.search.toString();
history.pushState({}, 'WorkAdventure', room.id+(search?'?'+search:'')+hash); history.pushState({}, "WorkAdventure", room.id + (search ? "?" + search : "") + hash);
} }
public getStartLayerNameFromUrl(): string | null { public getStartLayerNameFromUrl(): string | null {

View File

@ -291,6 +291,18 @@
dependencies: dependencies:
source-map "^0.6.1" source-map "^0.6.1"
"@types/uuid@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==
"@types/uuidv4@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/uuidv4/-/uuidv4-5.0.0.tgz#2c94e67b0c06d5adb28fb7ced1a1b5f0866ecd50"
integrity sha512-xUrhYSJnkTq9CP79cU3svoKTLPCIbMMnu9Twf/tMpHATYSHCAAeDNeb2a/29YORhk5p4atHhCTMsIBU/tvdh6A==
dependencies:
uuidv4 "*"
"@types/webpack-dev-server@^3.11.4": "@types/webpack-dev-server@^3.11.4":
version "3.11.4" version "3.11.4"
resolved "https://registry.yarnpkg.com/@types/webpack-dev-server/-/webpack-dev-server-3.11.4.tgz#90d47dd660b696d409431ab8c1e9fa3615103a07" resolved "https://registry.yarnpkg.com/@types/webpack-dev-server/-/webpack-dev-server-3.11.4.tgz#90d47dd660b696d409431ab8c1e9fa3615103a07"
@ -5775,11 +5787,24 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
uuid@8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
uuid@^3.3.2, uuid@^3.4.0: uuid@^3.3.2, uuid@^3.4.0:
version "3.4.0" version "3.4.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuidv4@*, uuidv4@^6.2.10:
version "6.2.10"
resolved "https://registry.yarnpkg.com/uuidv4/-/uuidv4-6.2.10.tgz#42fc1c12b6f85ad536c2c5c1e836079d1e15003c"
integrity sha512-FMo1exd9l5UvoUPHRR6NrtJ/OJRePh0ca7IhPwBuMNuYRqjtuh8lE3WDxAUvZ4Yss5FbCOsPFjyWJf9lVTEmnw==
dependencies:
"@types/uuid" "8.3.0"
uuid "8.3.2"
v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0: v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"

View File

@ -247,6 +247,8 @@ message RefreshRoomMessage{
message WorldFullMessage{ message WorldFullMessage{
} }
message TokenExpiredMessage{
}
message WorldConnexionMessage{ message WorldConnexionMessage{
string message = 2; string message = 2;
@ -278,6 +280,7 @@ message ServerToClientMessage {
RefreshRoomMessage refreshRoomMessage = 17; RefreshRoomMessage refreshRoomMessage = 17;
WorldConnexionMessage worldConnexionMessage = 18; WorldConnexionMessage worldConnexionMessage = 18;
EmoteEventMessage emoteEventMessage = 19; EmoteEventMessage emoteEventMessage = 19;
TokenExpiredMessage tokenExpiredMessage = 20;
} }
} }

View File

@ -49,6 +49,7 @@
"grpc": "^1.24.4", "grpc": "^1.24.4",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"openid-client": "^4.7.4",
"prom-client": "^12.0.0", "prom-client": "^12.0.0",
"query-string": "^6.13.3", "query-string": "^6.13.3",
"uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0", "uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0",

View File

@ -4,6 +4,7 @@ import { BaseController } from "./BaseController";
import { adminApi } from "../Services/AdminApi"; import { adminApi } from "../Services/AdminApi";
import { jwtTokenManager } from "../Services/JWTTokenManager"; import { jwtTokenManager } from "../Services/JWTTokenManager";
import { parse } from "query-string"; import { parse } from "query-string";
import { openIDClient } from "../Services/OpenIDClient";
export interface TokenInterface { export interface TokenInterface {
userUuid: string; userUuid: string;
@ -12,11 +13,58 @@ export interface TokenInterface {
export class AuthenticateController extends BaseController { export class AuthenticateController extends BaseController {
constructor(private App: TemplatedApp) { constructor(private App: TemplatedApp) {
super(); super();
this.openIDLogin();
this.openIDCallback();
this.register(); this.register();
this.verify();
this.anonymLogin(); this.anonymLogin();
} }
openIDLogin() {
//eslint-disable-next-line @typescript-eslint/no-misused-promises
this.App.get("/login-screen", async (res: HttpResponse, req: HttpRequest) => {
res.onAborted(() => {
console.warn("/message request was aborted");
});
const { nonce, state } = parse(req.getQuery());
if (!state || !nonce) {
res.writeStatus("400 Unauthorized").end("missing state and nonce URL parameters");
return;
}
try {
const loginUri = await openIDClient.authorizationUrl(state as string, nonce as string);
res.writeStatus("302");
res.writeHeader("Location", loginUri);
return res.end();
} catch (e) {
return this.errorToResponse(e, res);
}
});
}
openIDCallback() {
//eslint-disable-next-line @typescript-eslint/no-misused-promises
this.App.get("/login-callback", async (res: HttpResponse, req: HttpRequest) => {
res.onAborted(() => {
console.warn("/message request was aborted");
});
const { code, nonce } = parse(req.getQuery());
try {
const userInfo = await openIDClient.getUserInfo(code as string, nonce as string);
const email = userInfo.email || userInfo.sub;
if (!email) {
throw new Error("No email in the response");
}
const authToken = jwtTokenManager.createAuthToken(email);
res.writeStatus("200");
this.addCorsHeaders(res);
return res.end(JSON.stringify({ authToken }));
} catch (e) {
return this.errorToResponse(e, res);
}
});
}
//Try to login with an admin token //Try to login with an admin token
private register() { private register() {
this.App.options("/register", (res: HttpResponse, req: HttpRequest) => { this.App.options("/register", (res: HttpResponse, req: HttpRequest) => {
@ -39,11 +87,12 @@ export class AuthenticateController extends BaseController {
if (typeof organizationMemberToken != "string") throw new Error("No organization token"); if (typeof organizationMemberToken != "string") throw new Error("No organization token");
const data = await adminApi.fetchMemberDataByToken(organizationMemberToken); const data = await adminApi.fetchMemberDataByToken(organizationMemberToken);
const userUuid = data.userUuid; const userUuid = data.userUuid;
const email = data.email;
const roomUrl = data.roomUrl; const roomUrl = data.roomUrl;
const mapUrlStart = data.mapUrlStart; const mapUrlStart = data.mapUrlStart;
const textures = data.textures; const textures = data.textures;
const authToken = jwtTokenManager.createJWTToken(userUuid); const authToken = jwtTokenManager.createAuthToken(email || userUuid);
res.writeStatus("200 OK"); res.writeStatus("200 OK");
this.addCorsHeaders(res); this.addCorsHeaders(res);
res.end( res.end(
@ -63,45 +112,6 @@ export class AuthenticateController extends BaseController {
}); });
} }
private verify() {
this.App.options("/verify", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.end();
});
this.App.get("/verify", (res: HttpResponse, req: HttpRequest) => {
(async () => {
const query = parse(req.getQuery());
res.onAborted(() => {
console.warn("verify request was aborted");
});
try {
await jwtTokenManager.getUserUuidFromToken(query.token as string);
} catch (e) {
res.writeStatus("400 Bad Request");
this.addCorsHeaders(res);
res.end(
JSON.stringify({
success: false,
message: "Invalid JWT token",
})
);
return;
}
res.writeStatus("200 OK");
this.addCorsHeaders(res);
res.end(
JSON.stringify({
success: true,
})
);
})();
});
}
//permit to login on application. Return token to connect on Websocket IO. //permit to login on application. Return token to connect on Websocket IO.
private anonymLogin() { private anonymLogin() {
this.App.options("/anonymLogin", (res: HttpResponse, req: HttpRequest) => { this.App.options("/anonymLogin", (res: HttpResponse, req: HttpRequest) => {
@ -115,7 +125,7 @@ export class AuthenticateController extends BaseController {
}); });
const userUuid = v4(); const userUuid = v4();
const authToken = jwtTokenManager.createJWTToken(userUuid); const authToken = jwtTokenManager.createAuthToken(userUuid);
res.writeStatus("200 OK"); res.writeStatus("200 OK");
this.addCorsHeaders(res); this.addCorsHeaders(res);
res.end( res.end(

View File

@ -22,7 +22,7 @@ import {
import { UserMovesMessage } from "../Messages/generated/messages_pb"; import { UserMovesMessage } from "../Messages/generated/messages_pb";
import { TemplatedApp } from "uWebSockets.js"; import { TemplatedApp } from "uWebSockets.js";
import { parse } from "query-string"; import { parse } from "query-string";
import { jwtTokenManager } from "../Services/JWTTokenManager"; import { jwtTokenManager, tokenInvalidException } from "../Services/JWTTokenManager";
import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi";
import { SocketManager, socketManager } from "../Services/SocketManager"; import { SocketManager, socketManager } from "../Services/SocketManager";
import { emitInBatch } from "../Services/IoSocketHelpers"; import { emitInBatch } from "../Services/IoSocketHelpers";
@ -173,31 +173,34 @@ export class IoSocketController {
characterLayers = [characterLayers]; characterLayers = [characterLayers];
} }
const userUuid = await jwtTokenManager.getUserUuidFromToken(token, IPAddress, roomId); const tokenData =
token && typeof token === "string" ? jwtTokenManager.decodeJWTToken(token) : null;
const userIdentifier = tokenData ? tokenData.identifier : "";
let memberTags: string[] = []; let memberTags: string[] = [];
let memberVisitCardUrl: string | null = null; let memberVisitCardUrl: string | null = null;
let memberMessages: unknown; let memberMessages: unknown;
let memberTextures: CharacterTexture[] = []; let memberTextures: CharacterTexture[] = [];
const room = await socketManager.getOrCreateRoom(roomId); const room = await socketManager.getOrCreateRoom(roomId);
if (ADMIN_API_URL) {
try {
let userData: FetchMemberDataByUuidResponse = { let userData: FetchMemberDataByUuidResponse = {
uuid: v4(), userUuid: userIdentifier,
tags: [], tags: [],
visitCardUrl: null, visitCardUrl: null,
textures: [], textures: [],
messages: [], messages: [],
anonymous: true, anonymous: true,
}; };
if (ADMIN_API_URL) {
try { try {
userData = await adminApi.fetchMemberDataByUuid(userUuid, roomId); try {
userData = await adminApi.fetchMemberDataByUuid(userIdentifier, roomId, IPAddress);
} catch (err) { } catch (err) {
if (err?.response?.status == 404) { if (err?.response?.status == 404) {
// If we get an HTTP 404, the token is invalid. Let's perform an anonymous login! // If we get an HTTP 404, the token is invalid. Let's perform an anonymous login!
console.warn( console.warn(
'Cannot find user with uuid "' + 'Cannot find user with email "' +
userUuid + (userIdentifier || "anonymous") +
'". Performing an anonymous login instead.' '". Performing an anonymous login instead.'
); );
} else if (err?.response?.status == 403) { } else if (err?.response?.status == 403) {
@ -235,7 +238,12 @@ export class IoSocketController {
throw new Error("Use the login URL to connect"); throw new Error("Use the login URL to connect");
} }
} catch (e) { } catch (e) {
console.log("access not granted for user " + userUuid + " and room " + roomId); console.log(
"access not granted for user " +
(userIdentifier || "anonymous") +
" and room " +
roomId
);
console.error(e); console.error(e);
throw new Error("User cannot access this world"); throw new Error("User cannot access this world");
} }
@ -257,7 +265,7 @@ export class IoSocketController {
// Data passed here is accessible on the "websocket" socket object. // Data passed here is accessible on the "websocket" socket object.
url, url,
token, token,
userUuid, userUuid: userData.userUuid,
IPAddress, IPAddress,
roomId, roomId,
name, name,
@ -287,15 +295,10 @@ export class IoSocketController {
context context
); );
} catch (e) { } catch (e) {
/*if (e instanceof Error) { res.upgrade(
console.log(e.message);
res.writeStatus("401 Unauthorized").end(e.message);
} else {
res.writeStatus("500 Internal Server Error").end('An error occurred');
}*/
return res.upgrade(
{ {
rejected: true, rejected: true,
reason: e.reason || null,
message: e.message ? e.message : "500 Internal Server Error", message: e.message ? e.message : "500 Internal Server Error",
}, },
websocketKey, websocketKey,
@ -310,12 +313,14 @@ export class IoSocketController {
open: (ws) => { open: (ws) => {
if (ws.rejected === true) { if (ws.rejected === true) {
//FIX ME to use status code //FIX ME to use status code
if (ws.message === "World is full") { if (ws.reason === tokenInvalidException) {
socketManager.emitTokenExpiredMessage(ws);
} else if (ws.message === "World is full") {
socketManager.emitWorldFullMessage(ws); socketManager.emitWorldFullMessage(ws);
} else { } else {
socketManager.emitConnexionErrorMessage(ws, ws.message as string); socketManager.emitConnexionErrorMessage(ws, ws.message as string);
} }
ws.close(); setTimeout(() => ws.close(), 0);
return; return;
} }

View File

@ -1,11 +1,8 @@
const SECRET_KEY = process.env.SECRET_KEY || "THECODINGMACHINE_SECRET_KEY"; const SECRET_KEY = process.env.SECRET_KEY || "THECODINGMACHINE_SECRET_KEY";
const MINIMUM_DISTANCE = process.env.MINIMUM_DISTANCE ? Number(process.env.MINIMUM_DISTANCE) : 64;
const GROUP_RADIUS = process.env.GROUP_RADIUS ? Number(process.env.GROUP_RADIUS) : 48;
const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == "true" : false; const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == "true" : false;
const API_URL = process.env.API_URL || ""; const API_URL = process.env.API_URL || "";
const ADMIN_API_URL = process.env.ADMIN_API_URL || ""; const ADMIN_API_URL = process.env.ADMIN_API_URL || "";
const ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN || "myapitoken"; const ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN || "myapitoken";
const MAX_USERS_PER_ROOM = parseInt(process.env.MAX_USERS_PER_ROOM || "") || 600;
const CPU_OVERHEAT_THRESHOLD = Number(process.env.CPU_OVERHEAT_THRESHOLD) || 80; const CPU_OVERHEAT_THRESHOLD = Number(process.env.CPU_OVERHEAT_THRESHOLD) || 80;
const JITSI_URL: string | undefined = process.env.JITSI_URL === "" ? undefined : process.env.JITSI_URL; const JITSI_URL: string | undefined = process.env.JITSI_URL === "" ? undefined : process.env.JITSI_URL;
const JITSI_ISS = process.env.JITSI_ISS || ""; const JITSI_ISS = process.env.JITSI_ISS || "";
@ -13,14 +10,16 @@ const SECRET_JITSI_KEY = process.env.SECRET_JITSI_KEY || "";
const PUSHER_HTTP_PORT = parseInt(process.env.PUSHER_HTTP_PORT || "8080") || 8080; const PUSHER_HTTP_PORT = parseInt(process.env.PUSHER_HTTP_PORT || "8080") || 8080;
export const SOCKET_IDLE_TIMER = parseInt(process.env.SOCKET_IDLE_TIMER as string) || 30; // maximum time (in second) without activity before a socket is closed export const SOCKET_IDLE_TIMER = parseInt(process.env.SOCKET_IDLE_TIMER as string) || 30; // maximum time (in second) without activity before a socket is closed
export const FRONT_URL = process.env.FRONT_URL || "http://localhost";
export const OPID_CLIENT_ID = process.env.OPID_CLIENT_ID || "";
export const OPID_CLIENT_SECRET = process.env.OPID_CLIENT_SECRET || "";
export const OPID_CLIENT_ISSUER = process.env.OPID_CLIENT_ISSUER || "";
export { export {
SECRET_KEY, SECRET_KEY,
MINIMUM_DISTANCE,
API_URL, API_URL,
ADMIN_API_URL, ADMIN_API_URL,
ADMIN_API_TOKEN, ADMIN_API_TOKEN,
MAX_USERS_PER_ROOM,
GROUP_RADIUS,
ALLOW_ARTILLERY, ALLOW_ARTILLERY,
CPU_OVERHEAT_THRESHOLD, CPU_OVERHEAT_THRESHOLD,
JITSI_URL, JITSI_URL,

View File

@ -7,6 +7,7 @@ import { RoomRedirect } from "./AdminApi/RoomRedirect";
export interface AdminApiData { export interface AdminApiData {
roomUrl: string; roomUrl: string;
email: string | null;
mapUrlStart: string; mapUrlStart: string;
tags: string[]; tags: string[];
policy_type: number; policy_type: number;
@ -21,7 +22,7 @@ export interface AdminBannedData {
} }
export interface FetchMemberDataByUuidResponse { export interface FetchMemberDataByUuidResponse {
uuid: string; userUuid: string;
tags: string[]; tags: string[];
visitCardUrl: string | null; visitCardUrl: string | null;
textures: CharacterTexture[]; textures: CharacterTexture[];
@ -46,12 +47,16 @@ class AdminApi {
return res.data; return res.data;
} }
async fetchMemberDataByUuid(uuid: string, roomId: string): Promise<FetchMemberDataByUuidResponse> { async fetchMemberDataByUuid(
userIdentifier: string | null,
roomId: string,
ipAddress: string
): Promise<FetchMemberDataByUuidResponse> {
if (!ADMIN_API_URL) { if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!")); return Promise.reject(new Error("No admin backoffice set!"));
} }
const res = await Axios.get(ADMIN_API_URL + "/api/room/access", { const res = await Axios.get(ADMIN_API_URL + "/api/room/access", {
params: { uuid, roomId }, params: { userIdentifier, roomId, ipAddress },
headers: { Authorization: `${ADMIN_API_TOKEN}` }, headers: { Authorization: `${ADMIN_API_TOKEN}` },
}); });
return res.data; return res.data;

View File

@ -1,100 +1,25 @@
import { ADMIN_API_URL, ALLOW_ARTILLERY, SECRET_KEY } from "../Enum/EnvironmentVariable"; import { ADMIN_API_URL, ALLOW_ARTILLERY, SECRET_KEY } from "../Enum/EnvironmentVariable";
import { uuid } from "uuidv4"; import { uuid } from "uuidv4";
import Jwt from "jsonwebtoken"; import Jwt, { verify } from "jsonwebtoken";
import { TokenInterface } from "../Controller/AuthenticateController"; import { TokenInterface } from "../Controller/AuthenticateController";
import { adminApi, AdminBannedData } from "../Services/AdminApi"; import { adminApi, AdminBannedData } from "../Services/AdminApi";
export interface AuthTokenData {
identifier: string; //will be a email if logged in or an uuid if anonymous
}
export const tokenInvalidException = "tokenInvalid";
class JWTTokenManager { class JWTTokenManager {
public createJWTToken(userUuid: string) { public createAuthToken(identifier: string) {
return Jwt.sign({ userUuid: userUuid }, SECRET_KEY, { expiresIn: "200d" }); //todo: add a mechanic to refresh or recreate token return Jwt.sign({ identifier }, SECRET_KEY, { expiresIn: "3d" });
} }
public async getUserUuidFromToken(token: unknown, ipAddress?: string, roomUrl?: string): Promise<string> { public decodeJWTToken(token: string): AuthTokenData {
if (!token) { try {
throw new Error("An authentication error happened, a user tried to connect without a token."); return Jwt.verify(token, SECRET_KEY, { ignoreExpiration: false }) as AuthTokenData;
} catch (e) {
throw { reason: tokenInvalidException, message: e.message };
} }
if (typeof token !== "string") {
throw new Error("Token is expected to be a string");
}
if (token === "test") {
if (ALLOW_ARTILLERY) {
return uuid();
} else {
throw new Error(
"In order to perform a load-testing test on this environment, you must set the ALLOW_ARTILLERY environment variable to 'true'"
);
}
}
return new Promise<string>((resolve, reject) => {
Jwt.verify(token, SECRET_KEY, {}, (err, tokenDecoded) => {
const tokenInterface = tokenDecoded as TokenInterface;
if (err) {
console.error("An authentication error happened, invalid JsonWebToken.", err);
reject(new Error("An authentication error happened, invalid JsonWebToken. " + err.message));
return;
}
if (tokenDecoded === undefined) {
console.error("Empty token found.");
reject(new Error("Empty token found."));
return;
}
//verify token
if (!this.isValidToken(tokenInterface)) {
reject(new Error("Authentication error, invalid token structure."));
return;
}
if (ADMIN_API_URL) {
//verify user in admin
let promise = new Promise((resolve) => resolve());
if (ipAddress && roomUrl) {
promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, roomUrl);
}
promise
.then(() => {
adminApi
.fetchCheckUserByToken(tokenInterface.userUuid)
.then(() => {
resolve(tokenInterface.userUuid);
})
.catch((err) => {
//anonymous user
if (err.response && err.response.status && err.response.status === 404) {
resolve(tokenInterface.userUuid);
return;
}
reject(err);
});
})
.catch((err) => {
reject(err);
});
} else {
resolve(tokenInterface.userUuid);
}
});
});
}
private verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise<AdminBannedData> {
return adminApi
.verifyBanUser(userUuid, ipAddress, roomUrl)
.then((data: AdminBannedData) => {
if (data && data.is_banned) {
throw new Error("User was banned");
}
return data;
})
.catch((err) => {
throw err;
});
}
private isValidToken(token: object): token is TokenInterface {
return !(typeof (token as TokenInterface).userUuid !== "string");
} }
} }

View File

@ -0,0 +1,43 @@
import { Issuer, Client } from "openid-client";
import { OPID_CLIENT_ID, OPID_CLIENT_SECRET, OPID_CLIENT_ISSUER, FRONT_URL } from "../Enum/EnvironmentVariable";
const opidRedirectUri = FRONT_URL + "/jwt";
class OpenIDClient {
private issuerPromise: Promise<Client> | null = null;
private initClient(): Promise<Client> {
if (!this.issuerPromise) {
this.issuerPromise = Issuer.discover(OPID_CLIENT_ISSUER).then((issuer) => {
return new issuer.Client({
client_id: OPID_CLIENT_ID,
client_secret: OPID_CLIENT_SECRET,
redirect_uris: [opidRedirectUri],
response_types: ["code"],
});
});
}
return this.issuerPromise;
}
public authorizationUrl(state: string, nonce: string) {
return this.initClient().then((client) => {
return client.authorizationUrl({
scope: "openid email",
prompt: "login",
state: state,
nonce: nonce,
});
});
}
public getUserInfo(code: string, nonce: string): Promise<{ email: string; sub: string }> {
return this.initClient().then((client) => {
return client.callback(opidRedirectUri, { code }, { nonce }).then((tokenSet) => {
return client.userinfo(tokenSet);
});
});
}
}
export const openIDClient = new OpenIDClient();

View File

@ -28,6 +28,7 @@ import {
UserLeftRoomMessage, UserLeftRoomMessage,
AdminMessage, AdminMessage,
BanMessage, BanMessage,
TokenExpiredMessage,
RefreshRoomMessage, RefreshRoomMessage,
EmotePromptMessage, EmotePromptMessage,
VariableMessage, VariableMessage,
@ -117,7 +118,7 @@ export class SocketManager implements ZoneEventListener {
console.warn("Admin connection lost to back server"); console.warn("Admin connection lost to back server");
// Let's close the front connection if the back connection is closed. This way, we can retry connecting from the start. // Let's close the front connection if the back connection is closed. This way, we can retry connecting from the start.
if (!client.disconnecting) { if (!client.disconnecting) {
this.closeWebsocketConnection(client, 1011, "Connection lost to back server"); this.closeWebsocketConnection(client, 1011, "Admin Connection lost to back server");
} }
console.log("A user left"); console.log("A user left");
}) })
@ -140,24 +141,6 @@ export class SocketManager implements ZoneEventListener {
} }
} }
getAdminSocketDataFor(roomId: string): AdminSocketData {
throw new Error("Not reimplemented yet");
/*const data:AdminSocketData = {
rooms: {},
users: {},
}
const room = this.Worlds.get(roomId);
if (room === undefined) {
return data;
}
const users = room.getUsers();
data.rooms[roomId] = users.size;
users.forEach(user => {
data.users[user.uuid] = true
})
return data;*/
}
async handleJoinRoom(client: ExSocketInterface): Promise<void> { async handleJoinRoom(client: ExSocketInterface): Promise<void> {
const viewport = client.viewport; const viewport = client.viewport;
try { try {
@ -598,8 +581,20 @@ export class SocketManager implements ZoneEventListener {
const serverToClientMessage = new ServerToClientMessage(); const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setWorldfullmessage(errorMessage); serverToClientMessage.setWorldfullmessage(errorMessage);
if (!client.disconnecting) {
client.send(serverToClientMessage.serializeBinary().buffer, true); client.send(serverToClientMessage.serializeBinary().buffer, true);
} }
}
public emitTokenExpiredMessage(client: WebSocket) {
const errorMessage = new TokenExpiredMessage();
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setTokenexpiredmessage(errorMessage);
if (!client.disconnecting) {
client.send(serverToClientMessage.serializeBinary().buffer, true);
}
}
public emitConnexionErrorMessage(client: WebSocket, message: string) { public emitConnexionErrorMessage(client: WebSocket, message: string) {
const errorMessage = new WorldConnexionMessage(); const errorMessage = new WorldConnexionMessage();

File diff suppressed because it is too large Load Diff