feat(auth): Add role-based access management from OpenID Connect (#535)
* feat(auth): Add role-based access management from OpenID Connect Signed-off-by: Marvin A. Ruder <signed@mruder.dev> * Apply suggestions from code review Signed-off-by: Marvin A. Ruder <signed@mruder.dev> --------- Signed-off-by: Marvin A. Ruder <signed@mruder.dev>
This commit is contained in:
17
backend/package-lock.json
generated
17
backend/package-lock.json
generated
@@ -19,6 +19,7 @@
|
||||
"@nestjs/swagger": "^7.3.1",
|
||||
"@nestjs/throttler": "^5.2.0",
|
||||
"@prisma/client": "^5.16.1",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.40.3",
|
||||
"body-parser": "^1.20.2",
|
||||
@@ -28,6 +29,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"jmespath": "^0.16.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.30.1",
|
||||
"nanoid": "^3.3.7",
|
||||
@@ -1994,6 +1996,12 @@
|
||||
"@types/range-parser": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jmespath": {
|
||||
"version": "0.15.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz",
|
||||
"integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz",
|
||||
@@ -5523,6 +5531,15 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jmespath": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz",
|
||||
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "17.11.0",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@nestjs/swagger": "^7.3.1",
|
||||
"@nestjs/throttler": "^5.2.0",
|
||||
"@prisma/client": "^5.16.1",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.40.3",
|
||||
"body-parser": "^1.20.2",
|
||||
@@ -33,6 +34,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"jmespath": "^0.16.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"moment": "^2.30.1",
|
||||
"nanoid": "^3.3.7",
|
||||
|
||||
@@ -230,6 +230,18 @@ const configVariables: ConfigVariables = {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-rolePath": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-roleGeneralAccess": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-roleAdminAccess": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
},
|
||||
"oidc-clientId": {
|
||||
type: "string",
|
||||
defaultValue: "",
|
||||
|
||||
@@ -27,7 +27,7 @@ export class AuthService {
|
||||
) {}
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
async signUp(dto: AuthRegisterDTO, ip: string) {
|
||||
async signUp(dto: AuthRegisterDTO, ip: string, isAdmin?: boolean) {
|
||||
const isFirstUser = (await this.prisma.user.count()) == 0;
|
||||
|
||||
const hash = dto.password ? await argon.hash(dto.password) : null;
|
||||
@@ -37,7 +37,7 @@ export class AuthService {
|
||||
email: dto.email,
|
||||
username: dto.username,
|
||||
password: hash,
|
||||
isAdmin: isFirstUser,
|
||||
isAdmin: isAdmin ?? isFirstUser,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException("Wrong email or password");
|
||||
}
|
||||
|
||||
this.logger.log(`Successful login for user ${dto.email} from IP ${ip}`);
|
||||
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
||||
return this.generateToken(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ export interface OAuthSignInDto {
|
||||
providerId: string;
|
||||
providerUsername: string;
|
||||
email: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
@@ -46,13 +46,16 @@ export class OAuthService {
|
||||
provider: user.provider,
|
||||
providerUserId: user.providerId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
if (oauthUser) {
|
||||
await this.updateIsAdmin(user);
|
||||
const updatedUser = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
||||
return this.auth.generateToken(oauthUser.user, true);
|
||||
return this.auth.generateToken(updatedUser, true);
|
||||
}
|
||||
|
||||
return this.signUp(user, ip);
|
||||
@@ -150,6 +153,7 @@ export class OAuthService {
|
||||
userId: existingUser.id,
|
||||
},
|
||||
});
|
||||
await this.updateIsAdmin(user);
|
||||
return this.auth.generateToken(existingUser, true);
|
||||
}
|
||||
|
||||
@@ -160,6 +164,7 @@ export class OAuthService {
|
||||
password: null,
|
||||
},
|
||||
ip,
|
||||
user.isAdmin,
|
||||
);
|
||||
|
||||
await this.prisma.oAuthUser.create({
|
||||
@@ -173,4 +178,16 @@ export class OAuthService {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updateIsAdmin(user: OAuthSignInDto) {
|
||||
if ("isAdmin" in user)
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
data: {
|
||||
isAdmin: user.isAdmin,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,10 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
||||
});
|
||||
const guilds = (await res.json()) as DiscordPartialGuild[];
|
||||
if (!guilds.some((guild) => guild.id === guildId)) {
|
||||
throw new ErrorPageException("discord_guild_permission_denied");
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
} catch {
|
||||
throw new ErrorPageException("discord_guild_permission_denied");
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "../../config/config.service";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Cache } from "cache-manager";
|
||||
import * as jmespath from "jmespath";
|
||||
import { nanoid } from "nanoid";
|
||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||
@@ -108,6 +109,11 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
token: OAuthToken<OidcToken>,
|
||||
query: OAuthCallbackDto,
|
||||
claim?: string,
|
||||
roleConfig?: {
|
||||
path?: string;
|
||||
generalAccess?: string;
|
||||
adminAccess?: string;
|
||||
},
|
||||
): Promise<OAuthSignInDto> {
|
||||
const idTokenData = this.decodeIdToken(token.idToken);
|
||||
// maybe it's not necessary to verify the id token since it's directly obtained from the provider
|
||||
@@ -127,6 +133,39 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
: idTokenData.preferred_username ||
|
||||
idTokenData.name ||
|
||||
idTokenData.nickname;
|
||||
|
||||
let isAdmin: boolean;
|
||||
|
||||
if (roleConfig?.path) {
|
||||
// A path to read roles from the token is configured
|
||||
let roles: string[] | null;
|
||||
try {
|
||||
roles = jmespath.search(idTokenData, roleConfig.path);
|
||||
} catch (e) {
|
||||
roles = null;
|
||||
}
|
||||
if (Array.isArray(roles)) {
|
||||
// Roles are found in the token
|
||||
if (roleConfig.generalAccess && !roles.includes(roleConfig.generalAccess)) {
|
||||
// Role for general access is configured and the user does not have it
|
||||
this.logger.error(`User roles ${roles} do not include ${roleConfig.generalAccess}`);
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
if (roleConfig.adminAccess) {
|
||||
// Role for admin access is configured
|
||||
isAdmin = roles.includes(roleConfig.adminAccess);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Roles not found at path ${roleConfig.path} in ID Token ${JSON.stringify(
|
||||
idTokenData,
|
||||
undefined,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
throw new ErrorPageException("user_not_allowed");
|
||||
}
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
this.logger.error(
|
||||
@@ -146,6 +185,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||
email: idTokenData.email,
|
||||
providerId: idTokenData.sub,
|
||||
providerUsername: username,
|
||||
...(isAdmin !== undefined && { isAdmin }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ export class OidcProvider extends GenericOidcProvider {
|
||||
_?: string,
|
||||
): Promise<OAuthSignInDto> {
|
||||
const claim = this.config.get("oauth.oidc-usernameClaim") || undefined;
|
||||
return super.getUserInfo(token, query, claim);
|
||||
const rolePath = this.config.get("oauth.oidc-rolePath") || undefined;
|
||||
const roleGeneralAccess = this.config.get("oauth.oidc-roleGeneralAccess") || undefined;
|
||||
const roleAdminAccess = this.config.get("oauth.oidc-roleAdminAccess") || undefined;
|
||||
return super.getUserInfo(token, query, claim, {
|
||||
path: rolePath,
|
||||
generalAccess: roleGeneralAccess,
|
||||
adminAccess: roleAdminAccess,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user