Skip to content

Features/improve introspection #40

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 8, 2020
2 changes: 2 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,7 @@ services:
mongo:
container_name: mongo
image: mongo
ports:
- 27017:27017
logging:
driver: none
121 changes: 121 additions & 0 deletions src/config/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* NOTES:
* - Possible scopes: INTERNAL, PUBLIC, ADMIN, and USER
* - Base paths and routes should be all LOWERCASE
* - We only support routes of the following formats for the time being:
* - /resource
* - /resource/{id}
* - /resource/{id}/action
*/
export const basePaths = ["users", "auth", "problems", "problemsets"];

export const authConfig = {
"/users": {
get: {
scope: "USER",
userProtected: false
},
post: {
scope: "PUBLIC",
userProtected: false
}
},
"/users/{id}": {
get: {
scope: "USER",
userProtected: false
},
put: {
scope: "USER",
userProtected: true
},
delete: {
scope: "USER",
userProtected: true
}
},
"/users/resetlastsubmissions": {
patch: {
scope: "INTERNAL",
userProtected: false
}
},
"/users/{id}/problems": {
patch: {
scope: "USER",
userProtected: true
}
},
"/users/{id}/problemsets": {
patch: {
scope: "USER",
userProtected: true
}
},
"/auth/introspect": {
post: {
scope: "INTERNAL",
userProtected: false
}
},
"/auth/login": {
post: {
scope: "PUBLIC",
userProtected: false
}
},
"/problems": {
get: {
scope: "USER",
userProtected: false
},
post: {
scope: "ADMIN",
userProtected: false
}
},
"/problems/{id}": {
get: {
scope: "USER",
userProtected: false
},
put: {
scope: "ADMIN",
userProtected: false
},
delete: {
scope: "ADMIN",
userProtected: false
}
},
"/problems/{id}/exists": {
get: {
scope: "INTERNAL",
userProtected: false
}
},
"/problemsets": {
get: {
scope: "USER",
userProtected: false
},
post: {
scope: "ADMIN",
userProtected: false
}
},
"/problemsets/{id}": {
get: {
scope: "USER",
userProtected: false
},
put: {
scope: "ADMIN",
userProtected: false
},
delete: {
scope: "ADMIN",
userProtected: false
}
}
};
1 change: 1 addition & 0 deletions src/config/statusCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const statusCodes = {
SUCCESS: 200,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
CONFLICT_FOUND: 409,
NOT_FOUND: 404,
MISSING_PARAMS: 422,
Expand Down
38 changes: 33 additions & 5 deletions src/controllers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IUserModel } from "../database/models/user";
import { bcryptPassword } from "../config/bcrypt";
import { statusCodes } from "../config/statusCodes";
import { codeforces } from "../util/codeforces";
import { auth } from "../util/auth";

const authController = {
login: async (req: Request, res: Response) => {
Expand Down Expand Up @@ -66,12 +67,39 @@ const authController = {
);
} else {
try {
const { token } = req.query;
const { token, uri, method } = req.query;

const { route, resourceUserId } = auth.parseUri(decodeURI(uri));
if (!route) {
res.status(statusCodes.NOT_FOUND).json({
status: statusCodes.NOT_FOUND,
message: "Invalid route",
active: false
});
return;
}

const payload = jwt.verify(token, process.env.SECRET);
res.status(statusCodes.SUCCESS).json({
active: true,
user: payload
});
const isAuthorized = auth.authorize(
route,
method.toLowerCase(),
payload["role"],
payload["id"],
resourceUserId
);

if (isAuthorized) {
res.status(statusCodes.SUCCESS).json({
active: true,
user: payload
});
} else {
res.status(statusCodes.FORBIDDEN).json({
status: statusCodes.FORBIDDEN,
message: "Forbidden",
active: false
});
}
} catch (error) {
res.status(statusCodes.UNAUTHORIZED).json({
status: statusCodes.UNAUTHORIZED,
Expand Down
88 changes: 88 additions & 0 deletions src/util/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { authConfig, basePaths } from "../config/auth";

export const auth = {
parseUri: (uri: string): { route: string; resourceUserId: string } => {
// load list of routes
const routes: string[] = Object.keys(authConfig);
// remove query params
uri = uri.split("?")[0];
// remove extra "/" at the end if it's there
if (uri[uri.length - 1] == "/") uri = uri.substr(0, uri.length - 1);

// check if there is a route matching the uri
// (saves time if the route doesn't have route params)
if (routes.includes(uri.toLowerCase()))
return { route: uri.toLowerCase(), resourceUserId: "" };
else {
// split uri and remove 1st empty position
const uriSlice = uri.split("/");
uriSlice.shift();
// extract id (can be any resource id but only user
// ids will be used in case of a user-protected route)
const id = uriSlice.length > 1 ? uriSlice[1] : "";

// lowercase all array elements
for (let i = 0; i < uriSlice.length; i++)
uriSlice[i] = uriSlice[i].toLowerCase();

// get base path
const basePath = uriSlice[0];

// if basepath doesn't exist, then route doesn't exist
if (!basePaths.includes(basePath))
return { route: "", resourceUserId: "" };
else {
// remove the id variable to something consistent
if (id) uriSlice[1] = "{id}";
return {
route: "/" + uriSlice.join("/"),
resourceUserId: id
};
}
}
},

authorize: (
route: string,
method: string,
role: string,
requestUserId: string,
resourceUserId: string
): boolean => {
// default to rejection
// i.e. all unregistered routes are internal by default
if (!(authConfig[route] && authConfig[route][method])) return false;

const routeConfig = authConfig[route][method];

switch (routeConfig.scope) {
case "INTERNAL":
// reject right away since internal requests won't pass by the
// introspection route
return false;
case "PUBLIC":
// accept right away since public requests won't pass by the
// introspection route
return true;
case "ADMIN":
// check if role is admin
return role === "ADMIN";
case "USER":
// check if role is user or higher
const authorizedInUserScope =
role === "ADMIN" || role === "USER";

if (routeConfig.userProtected) {
return (
resourceUserId &&
requestUserId === resourceUserId &&
authorizedInUserScope
);
}

return authorizedInUserScope;
default:
return false;
}
}
};
8 changes: 7 additions & 1 deletion src/validators/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { body, query, ValidationChain } from "express-validator/check";
import { validHttpMethod } from "./authCustom";

export function authValidator(method: string): ValidationChain[] {
switch (method) {
Expand All @@ -11,7 +12,12 @@ export function authValidator(method: string): ValidationChain[] {
];
}
case "POST /auth/introspect": {
return [query("token", "Missing 'token'").exists()];
return [
query("token", "Missing 'token'").exists(),
query("uri", "Missing 'uri'").exists().isLength({ min: 1 }),
query("method", "Missing 'method'").exists(),
query("method", "Invalid 'method'").custom(validHttpMethod)
];
}
}
}
4 changes: 4 additions & 0 deletions src/validators/authCustom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const validHttpMethod = (method: string): boolean => {
const httpMethods = ["POST", "GET", "PUT", "PATCH", "DELETE"];
return httpMethods.includes(method.toUpperCase());
};
Loading