Skip to content

Commit 1d85641

Browse files
committed
feat(jsbattle-server): add OAuth to admin services
1 parent 065d525 commit 1d85641

19 files changed

+1509
-586
lines changed

packages/jsbattle-docs/docs/_sidebar.md

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
- [**Home**](./README.md)
44
- [**Installation**](./installation.md)
5+
- [**Configuration**](./configuration.md)
56
- [**Getting Started**](./getting_started.md)
67
- [**Manual**](./manual/README.md)
78
- [Battle Anatomy](./manual/battle_anatomy.md)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Configuration
2+
3+
## CLI
4+
5+
You can configure JsBattle by providing shell arguments on startup. To get more information run
6+
7+
```bash
8+
./jsbattle.js --help
9+
```
10+
11+
## Configuration file
12+
Most of the configuration is done via a configuration file.
13+
14+
```bash
15+
./jsbattle.js start --config jsbattle.config.json
16+
```
17+
18+
When the config file is not provided, the default configuration is applied. If any value is missing in the config file, default settings are used. CLI arguments have a higher priority than settings from the config file.
19+
20+
## Full configuration file
21+
List of all settings of JsBattle:
22+
23+
```js
24+
{
25+
// supported log levels: "error", "warn", "info", "debug", "trace"
26+
"loglevel": "info",
27+
28+
// data persistence settings
29+
"data": {
30+
31+
// path to directory where the data is stored. When not provided, an in-memory DB is used
32+
"path": "./jsbattle-data"
33+
},
34+
35+
// configuration of web server
36+
"web": {
37+
38+
// path to static files. In most cases you do not need to change that
39+
"webroot": path.resolve(__dirname, "./public"),
40+
41+
// host where web server will bind to
42+
"host": "127.0.0.1",
43+
44+
// port where web server will bind to
45+
"port": "8080",
46+
47+
// url where JsBattle is available. Required to generate URL links properly (e.g. oAth callback)
48+
"baseUrl": "http://localhost:8080",
49+
50+
// Google Analytics code (stats are disabled when not provided)
51+
"gaCode": ""
52+
},
53+
54+
// authorization settings
55+
"auth": {
56+
57+
// if auth is disabled, login will not be required. It is not recommended for production
58+
// since it will result in exposing admin panel to public. Some features may be disabled
59+
// when auth is turned off
60+
"enabled": true,
61+
62+
// list of users that will be granted administration permissions (admin role)
63+
"admins": [
64+
{
65+
"provider": "github",
66+
"username": "thejack6318"
67+
}
68+
],
69+
70+
// list of OAuth providers. For each providers there are two routes created:
71+
// - /auth/{provider_name} - redirects to provider login page
72+
// - /auth/{provider_name}/callback - retrieve oAuth token from provider
73+
"providers": [
74+
75+
// define each provider as separate object
76+
{
77+
// name of provider. Supported values are: github, facebook, google, twitter, linkedin, slack
78+
// authStrategies['github'] = require("passport-github2");
79+
"name": 'github',
80+
81+
// client ID generated by OAuth provider
82+
"clientID": "XXXXXXXXXXXXXXXXXXXX",
83+
// client secret generated by OAuth provider
84+
"clientSecret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
85+
}
86+
]
87+
}
88+
}
89+
```
90+
91+
## Environment variables
92+
93+
Part of the configuration could be provided as env vars.
94+
95+
### OAuth providers
96+
To configure OAuth provider set up following env vars
97+
98+
```bash
99+
OAUTH_{PROVIDER_NAME}_CLIENT_ID=XXXXXXXXXXXXXXXXXXXX
100+
OAUTH_{PROVIDER_NAME}_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
101+
```
102+
103+
Where `{PROVIDER_NAME}` is one of supported providers (upper case)

packages/jsbattle-server/.eslintrc.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ module.exports = {
168168
"no-param-reassign": "off",
169169
"no-path-concat": "off",
170170
"no-plusplus": "error",
171-
"no-process-env": "error",
171+
"no-process-env": "off",
172172
"no-process-exit": "error",
173173
"no-prototype-builtins": "error",
174174
"no-restricted-globals": "error",

packages/jsbattle-server/app/Gateway.js

+21-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const { ServiceBroker } = require("moleculer");
22
const path = require('path')
33
const _ = require('lodash')
4+
require('dotenv').config();
45

56
class Gateway {
67

@@ -14,17 +15,36 @@ class Gateway {
1415
"web": {
1516
"webroot": path.resolve(__dirname, "./public"),
1617
"host": "127.0.0.1",
18+
"baseUrl": "http://localhost:8080",
1719
"port": "8080",
1820
"gaCode": ""
21+
},
22+
"auth": {
23+
"enabled": true,
24+
"admins": [],
25+
"providers": []
1926
}
2027
};
28+
29+
// add auth strategioes defined in env vars
30+
let authStrategies = Object.keys(process.env)
31+
.filter((keyName) => (/^OAUTH_([A-Z_\-0-9]*)_CLIENT_(SECRET|ID)$/).test(keyName))
32+
.map((keyName) => keyName.replace(/^OAUTH_([A-Z_\-0-9]*)_CLIENT_(SECRET|ID)$/, "$1"))
33+
authStrategies = _.uniq(authStrategies);
34+
authStrategies.forEach((strategyName) => {
35+
defaultOptions.auth.providers.push({
36+
name: strategyName.toLowerCase(),
37+
clientID: process.env[`OAUTH_${strategyName.toUpperCase()}_CLIENT_ID`],
38+
clientSecret: process.env[`OAUTH_${strategyName.toUpperCase()}_CLIENT_SECRET`]
39+
});
40+
});
41+
2142
options = _.defaultsDeep(options, defaultOptions)
2243
this.broker = new ServiceBroker({
2344
namespace: 'jsbattle',
2445
logLevel: options.loglevel
2546
});
2647
this.broker.serviceConfig = options;
27-
2848
this.broker.loadServices(path.resolve(__dirname, 'services'), '*.service.js');
2949
resolve();
3050
});

packages/jsbattle-server/app/public/index.html

+1
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
<body>
66
<h1>JsBattle</h1>
77
<!-- GA Tracking code: GA:XX-XXXXXXXXX-X -->
8+
<a href="/auth/github">Github Login</a> | <a href="/auth/logout">Log out</a>
89
</body>
910
</html>
+14-4
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
#!/usr/bin/env node
22

3-
const Gateway = require('./Gateway.js')
3+
const Gateway = require('./Gateway.js');
44
const path = require('path');
55

66
let gateway = new Gateway();
7-
gateway.init({
8-
"loglevel": "info",
7+
let config = {
8+
"loglevel": "debug",
99
"data": {
1010
"path": path.join(__dirname, 'jsbattle-data')
1111
},
1212
"web": {
1313
"webroot": path.join(__dirname, 'public'),
1414
"host": "127.0.0.1",
15+
"baseUrl": "http://localhost:9000",
1516
"port": "9000",
1617
"gaCode": "AB-123456789-Z"
18+
},
19+
"auth": {
20+
"admins": [
21+
{
22+
provider: 'github',
23+
username: 'jamro'
24+
}
25+
]
1726
}
18-
}).then(() => gateway.start())
27+
};
28+
gateway.init(config).then(() => gateway.start())
1929
.catch(console.error);

packages/jsbattle-server/app/services/ApiGateway.service.js

+18-24
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
const Service = require("moleculer").Service;
22
const ApiService = require("moleculer-web");
33
const express = require('express');
4+
const cookieParser = require('cookie-parser');
45
const path = require('path');
56
const stringReplace = require('../lib/stringReplaceMiddleware.js');
6-
const { UnAuthorizedError, ERR_NO_TOKEN, ERR_INVALID_TOKEN } = require("moleculer-web").Errors;
7+
const authorize = require('./apiGateway/authorize.js');
8+
const configPassport = require('./apiGateway/configPassport.js');
79

810
class ApiGatewayService extends Service {
911

@@ -18,11 +20,13 @@ class ApiGatewayService extends Service {
1820
settings: {
1921
routes: [
2022
{
21-
authorization: true,
23+
authorization: broker.serviceConfig.auth.enabled,
2224
path: '/admin',
2325
mappingPolicy: 'restrict',
26+
use: [cookieParser()],
2427
aliases: {
2528
"GET allBattleReplays": "battleStore.listAll",
29+
"GET whoami": "auth.whoami"
2630
},
2731
bodyParsers: {
2832
json: true,
@@ -32,10 +36,10 @@ class ApiGatewayService extends Service {
3236
{
3337
path: '/',
3438
mappingPolicy: 'restrict',
39+
use: [cookieParser()],
3540
aliases: {
3641
"GET battleReplay": "battleStore.getReplay",
37-
"POST battleReplay": "battleStore.publish",
38-
"POST login": "auth.login"
42+
"POST battleReplay": "battleStore.publish"
3943
},
4044
bodyParsers: {
4145
json: true,
@@ -50,20 +54,7 @@ class ApiGatewayService extends Service {
5054
}
5155
},
5256
methods: {
53-
async authorize(ctx, route, req) {
54-
let auth = req.headers["authorization"];
55-
if (auth && auth.startsWith("Bearer ")) {
56-
let token = auth.slice(7);
57-
try {
58-
let user = await ctx.call('auth.resolveToken', {token});
59-
ctx.meta.user = user; // eslint-disable-line require-atomic-updates
60-
} catch (err) {
61-
return Promise.reject(new UnAuthorizedError(ERR_INVALID_TOKEN));
62-
}
63-
} else {
64-
return Promise.reject(new UnAuthorizedError(ERR_NO_TOKEN));
65-
}
66-
}
57+
authorize: authorize(['admin'])
6758
},
6859
started() {
6960
// do not start listening since its an express middleware
@@ -85,6 +76,15 @@ class ApiGatewayService extends Service {
8576
{ maxAge: 12*60*60*1000, etag: true }
8677
));
8778
this.app.use("/api", svc.express());
79+
80+
this.app.use(cookieParser());
81+
82+
if(broker.serviceConfig.auth.enabled == false) {
83+
this.logger.warn('Auth is disabled. Everyone can access admin panel. The configuration is not recommended for production purposes');
84+
} else {
85+
configPassport(this.app, this.logger, broker);
86+
}
87+
8888
let port = broker.serviceConfig.web.port || 8080;
8989
let host = broker.serviceConfig.web.host || '127.0.0.1';
9090
this.app.listen(
@@ -98,12 +98,6 @@ class ApiGatewayService extends Service {
9898
});
9999
}
100100

101-
started() {
102-
this.app = express();
103-
this.app.use("/api", this.express());
104-
this.app.listen(3000);
105-
}
106-
107101
}
108102

109103
module.exports = ApiGatewayService;

packages/jsbattle-server/app/services/Auth.service.js

+29-21
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
const Service = require("moleculer").Service;
22
const { ValidationError } = require("moleculer").Errors;
3-
const { MoleculerClientError } = require("moleculer").Errors;
43
const jsonwebtoken = require("jsonwebtoken");
5-
const _ = require('lodash')
4+
const _ = require('lodash');
65
const crypto = require('crypto');
76

87
const JWT_SECRET = crypto.randomBytes(256).toString('base64');
8+
const JWT_FIELDS = [
9+
'userId',
10+
'username',
11+
'role'
12+
];
913

1014
class AuthService extends Service {
1115

@@ -15,28 +19,32 @@ class AuthService extends Service {
1519
this.parseServiceSchema({
1620
name: "auth",
1721
actions: {
18-
login: this.login,
19-
resolveToken: this.resolveToken
22+
authorize: this.authorize,
23+
resolveToken: this.resolveToken,
24+
whoami: this.whoami
25+
},
26+
events: {
27+
"user.login": async (userId) => {
28+
await broker.call("userStore.update", {id: userId, lastLoginAt: new Date()});
29+
}
2030
}
2131
});
2232
}
2333

24-
login(ctx) {
25-
if(!ctx.params.username) {
26-
throw new ValidationError('username parameter is required', 400);
27-
}
28-
if(!ctx.params.password) {
29-
throw new ValidationError('password parameter is required', 400);
30-
}
31-
if(ctx.params.username !== 'admin' || ctx.params.password !== 'secret' ) {
32-
throw new MoleculerClientError('Forbidden', 403);
34+
whoami(ctx) {
35+
let userId = ctx.meta.user.userId;
36+
let user = ctx.call('userStore.get', {id: userId});
37+
return user;
38+
}
39+
40+
authorize(ctx) {
41+
if(!ctx.params.user) {
42+
throw new ValidationError('user parameter is required', 400);
3343
}
44+
let user = _.pick(ctx.params.user, JWT_FIELDS)
3445
return {
3546
token: jsonwebtoken.sign(
36-
{
37-
username: ctx.params.username,
38-
role: 'admin'
39-
},
47+
user,
4048
JWT_SECRET,
4149
{
4250
expiresIn: '1d'
@@ -46,11 +54,11 @@ class AuthService extends Service {
4654
}
4755

4856
resolveToken(ctx) {
57+
if(!ctx.params.token) {
58+
throw new ValidationError('token parameter is required', 400);
59+
}
4960
let user = jsonwebtoken.verify(ctx.params.token, JWT_SECRET);
50-
return _.pick(user, [
51-
'username',
52-
'role'
53-
]);
61+
return _.pick(user, JWT_FIELDS);
5462
}
5563

5664
}

0 commit comments

Comments
 (0)