Skip to content

Commit

Permalink
User Authentication (#124)
Browse files Browse the repository at this point in the history
* Simplistic user-system with full MML-Support for characters

A simplistic User-System allowing authorization, usernames and different MML-Characters with (permissioned) inventory for global and unique items.
This is easily adapted to centralized databases and NFT-based blockchain-applications.

Refer the User-System section in README.md for examples and details.

Technically
- Adds UserId which is hardcoded into the web-client
-
- A simplistic protocol with UserData message (for client-initated
  changes) and UserUpdate-message (for server-authorized distribution of
user-details such as username, character, ..)
- A simplistic inventory system supporting unique items, which can at
  most be used once within a 3d-web-experience.

Code structured in a way s.t. it can easily be adapted to actual
applications and authorization structures

* User networking auth with chat

* Allow reusing sessions to aid developer experience

* Fix ExampleEnforcingUserAuthenticator

---------

Co-authored-by: Thomas Bergmueller <[email protected]>
Co-authored-by: Marco Gomez <[email protected]>
  • Loading branch information
3 people authored Apr 24, 2024
1 parent 8e179c2 commit a61f041
Show file tree
Hide file tree
Showing 28 changed files with 1,368 additions and 322 deletions.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ contains:
`@mml-io/3d-web-user-networking`
- Additionally, the server runs MML documents in the `mml-documents` directory which are then
connected to by the `web-client`.
- A simple session-based [auth system](#auth) that can be used as a reference to implement arbitrary http-based auth.

It can be easily deployed to environments that support Node.js and expose ports to the internet.

<img src="./playground.png">
<img src="./playground.jpg">

## Main features

Expand All @@ -44,3 +45,9 @@ npm run iterate
```

Once the example server is running, open `http://localhost:8080` in your browser.

## Auth
- When the client page is rendered by the server the server uses a UserAuthenticator implementation to determine if a session should be generated for the incoming http request and if so includes that session token on the client page.
- The client then sends the session token in the first message to the server when it connects via websocket.
- The server can use the session token to authenticate the user and determine what identity (username, avatar etc) the user should have.
- An example implementation of this is provided in the example server, but the interface is extensible enough that a more complex user authenticator can limit which avatar components should be permitted based on external systems.
7 changes: 5 additions & 2 deletions example/local-multi-web-client/src/LocalAvatarClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ export class LocalAvatarClient {
localAvatarServer.send(localClientId, characterState);
},
animationConfig,
characterDescription,
() => {
return { username: "User", characterDescription };
},
);
this.scene.add(this.characterManager.group);

Expand All @@ -143,8 +145,9 @@ export class LocalAvatarClient {
this.scene.add(room);

this.characterManager.spawnLocalCharacter(
characterDescription,
localClientId,
"User",
characterDescription,
spawnPosition,
spawnRotation,
);
Expand Down
3 changes: 3 additions & 0 deletions example/server/build.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as esbuild from "esbuild";

import { rebuildOnDependencyChangesPlugin } from "../../utils/rebuildOnDependencyChangesPlugin";

const buildMode = "--build";
const watchMode = "--watch";

Expand All @@ -24,6 +26,7 @@ const buildOptions: esbuild.BuildOptions = {
sourcemap: true,
platform: "node",
target: "es2020",
plugins: mode === watchMode ? [rebuildOnDependencyChangesPlugin] : [],
};

switch (mode) {
Expand Down
6 changes: 0 additions & 6 deletions example/server/nodemon.json

This file was deleted.

3 changes: 2 additions & 1 deletion example/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"type": "module",
"scripts": {
"build": "rimraf ./build && tsx ./build.ts --build",
"iterate": "concurrently \"tsx ./build.ts --watch\" \"nodemon\"",
"iterate": "tsx ./build.ts --watch",
"iterate:start": "node ./build/index.js",
"start": "NODE_ENV=production node build/index.js",
"type-check": "tsc --noEmit",
"lint": "eslint \"./{src,test}/**/*.{js,jsx,ts,tsx}\" --max-warnings 0",
Expand Down
113 changes: 113 additions & 0 deletions example/server/src/BasicUserAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import crypto from "crypto";

import { UserIdentity } from "@mml-io/3d-web-user-networking";
import type { CharacterDescription, UserData } from "@mml-io/3d-web-user-networking";
import express from "express";

export type AuthUser = {
// clientId is the connection identifier for the user - it is null before the client websocket is connected
clientId: number | null;
// userData is the user's presentation in the world (username and character description)
userData?: UserData;
// sessionToken is the token that is generated by this authenticator and the user uses to authenticate their websocket connection
sessionToken: string;
};

export type BasicUserAuthenticatorOptions = {
devAllowUnrecognizedSessions: boolean;
};

const defaultOptions: BasicUserAuthenticatorOptions = {
devAllowUnrecognizedSessions: false,
};

export class BasicUserAuthenticator {
private usersByClientId = new Map<number, AuthUser>();
private userBySessionToken = new Map<string, AuthUser>();

constructor(
private characterDescription: CharacterDescription,
private options: BasicUserAuthenticatorOptions = defaultOptions,
) {}

public generateAuthorizedSessionToken(req: express.Request): string {
const sessionToken = crypto.randomBytes(20).toString("hex");
const authUser: AuthUser = {
clientId: null,
sessionToken,
};

this.userBySessionToken.set(sessionToken, authUser);
return sessionToken;
}

public onClientConnect(
clientId: number,
sessionToken: string,
userIdentityPresentedOnConnection?: UserIdentity,
): UserData | null {
console.log(`Client ID: ${clientId} joined with token`);
let user = this.userBySessionToken.get(sessionToken);
if (!user) {
console.error(`Invalid initial user-update for clientId ${clientId}, unknown session`);

if (this.options.devAllowUnrecognizedSessions) {
console.warn(`Dev mode: allowing unrecognized session token`);
user = {
clientId: null,
sessionToken,
};
this.userBySessionToken.set(sessionToken, user);
}
return null;
}

if (user.clientId !== null) {
console.error(`Session token already connected`);
return null;
}

user.clientId = clientId;
user.userData = {
username: `User ${clientId}`,
characterDescription: this.characterDescription,
};
if (userIdentityPresentedOnConnection) {
console.warn("Ignoring user-identity on initial connect");
}
this.usersByClientId.set(clientId, user);
return user.userData;
}

public getClientIdForSessionToken(sessionToken: string): { id: number } | null {
const user = this.userBySessionToken.get(sessionToken);
if (!user) {
console.error("getClientIdForSessionToken - unknown session");
return null;
}
if (user.clientId === null) {
console.error("getClientIdForSessionToken - client not connected");
return null;
}
return { id: user.clientId };
}

public onClientUserIdentityUpdate(clientId: number, msg: UserIdentity): UserData | null {
// This implementation does not allow updating user data after initial connect.

// To allow updating user data after initial connect, return the UserData object that reflects the requested change.

// Returning null will not update the user data.
return null;
}

public onClientDisconnect(clientId: number) {
console.log(`Remove user-session for ${clientId}`);
// TODO - expire session token after a period of disconnection
const userData = this.usersByClientId.get(clientId);
if (userData) {
userData.clientId = null;
this.usersByClientId.delete(clientId);
}
}
}
Loading

0 comments on commit a61f041

Please sign in to comment.