mirror of
https://github.com/stonith404/pingvin-share.git
synced 2024-11-16 12:20:13 +01:00
feat(oauth): limited discord server sign-in (#346)
* feat(oauth): limited discord server sign-in * fix: typo * style: change undefined to optional * style: remove conditional operator
This commit is contained in:
parent
d9a9523c9a
commit
5f94c7295a
@ -180,6 +180,10 @@ const configVariables: ConfigVariables = {
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: "false",
|
defaultValue: "false",
|
||||||
},
|
},
|
||||||
|
"discord-limitedGuild": {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
"discord-clientId": {
|
"discord-clientId": {
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
@ -262,8 +266,8 @@ async function migrateConfigVariables() {
|
|||||||
for (const existingConfigVariable of existingConfigVariables) {
|
for (const existingConfigVariable of existingConfigVariables) {
|
||||||
const configVariable =
|
const configVariable =
|
||||||
configVariables[existingConfigVariable.category]?.[
|
configVariables[existingConfigVariable.category]?.[
|
||||||
existingConfigVariable.name
|
existingConfigVariable.name
|
||||||
];
|
];
|
||||||
if (!configVariable) {
|
if (!configVariable) {
|
||||||
await prisma.config.delete({
|
await prisma.config.delete({
|
||||||
where: {
|
where: {
|
||||||
|
@ -7,7 +7,7 @@ export class ErrorPageException extends Error {
|
|||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
public readonly key: string = "default",
|
public readonly key: string = "default",
|
||||||
public readonly redirect: string = "/",
|
public readonly redirect?: string,
|
||||||
public readonly params?: string[],
|
public readonly params?: string[],
|
||||||
) {
|
) {
|
||||||
super("error");
|
super("error");
|
||||||
|
@ -9,14 +9,27 @@ export class ErrorPageExceptionFilter implements ExceptionFilter {
|
|||||||
constructor(private config: ConfigService) {}
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
catch(exception: ErrorPageException, host: ArgumentsHost) {
|
catch(exception: ErrorPageException, host: ArgumentsHost) {
|
||||||
this.logger.error(exception);
|
this.logger.error(
|
||||||
|
JSON.stringify({
|
||||||
|
error: exception.key,
|
||||||
|
params: exception.params,
|
||||||
|
redirect: exception.redirect,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse();
|
const response = ctx.getResponse();
|
||||||
|
|
||||||
const url = new URL(`${this.config.get("general.appUrl")}/error`);
|
const url = new URL(`${this.config.get("general.appUrl")}/error`);
|
||||||
url.searchParams.set("redirect", exception.redirect);
|
|
||||||
url.searchParams.set("error", exception.key);
|
url.searchParams.set("error", exception.key);
|
||||||
|
if (exception.redirect) {
|
||||||
|
url.searchParams.set("redirect", exception.redirect);
|
||||||
|
} else {
|
||||||
|
const redirect = ctx.getRequest().cookies.access_token
|
||||||
|
? "/account"
|
||||||
|
: "/auth/signIn";
|
||||||
|
url.searchParams.set("redirect", redirect);
|
||||||
|
}
|
||||||
if (exception.params) {
|
if (exception.params) {
|
||||||
url.searchParams.set("params", exception.params.join(","));
|
url.searchParams.set("params", exception.params.join(","));
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,19 @@
|
|||||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
import { ConfigService } from "../../config/config.service";
|
||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||||
import { ConfigService } from "../../config/config.service";
|
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||||
import fetch from "node-fetch";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
||||||
constructor(private config: ConfigService) {}
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
getAuthEndpoint(state: string): Promise<string> {
|
getAuthEndpoint(state: string): Promise<string> {
|
||||||
|
let scope = "identify email";
|
||||||
|
if (this.config.get("oauth.discord-limitedGuild")) {
|
||||||
|
scope += " guilds";
|
||||||
|
}
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
"https://discord.com/api/oauth2/authorize?" +
|
"https://discord.com/api/oauth2/authorize?" +
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
@ -17,8 +21,8 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
|||||||
redirect_uri:
|
redirect_uri:
|
||||||
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
state: state,
|
state,
|
||||||
scope: "identify email",
|
scope,
|
||||||
}).toString(),
|
}).toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -69,7 +73,14 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
|||||||
});
|
});
|
||||||
const user = (await res.json()) as DiscordUser;
|
const user = (await res.json()) as DiscordUser;
|
||||||
if (user.verified === false) {
|
if (user.verified === false) {
|
||||||
throw new BadRequestException("Unverified account.");
|
throw new ErrorPageException("unverified_account", undefined, [
|
||||||
|
"provider_discord",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = this.config.get("oauth.discord-limitedGuild");
|
||||||
|
if (guild) {
|
||||||
|
await this.checkLimitedGuild(token, guild);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -79,6 +90,24 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkLimitedGuild(token: OAuthToken<DiscordToken>, guildId: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://discord.com/api/v10/users/@me/guilds", {
|
||||||
|
method: "get",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `${token.tokenType || "Bearer"} ${token.accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const guilds = (await res.json()) as DiscordPartialGuild[];
|
||||||
|
if (!guilds.some((guild) => guild.id === guildId)) {
|
||||||
|
throw new ErrorPageException("discord_guild_permission_denied");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new ErrorPageException("discord_guild_permission_denied");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscordToken {
|
export interface DiscordToken {
|
||||||
@ -96,3 +125,12 @@ export interface DiscordUser {
|
|||||||
email: string;
|
email: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscordPartialGuild {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
owner: boolean;
|
||||||
|
permissions: string;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { ConfigService } from "../../config/config.service";
|
import { ConfigService } from "../../config/config.service";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
@ -7,11 +7,15 @@ import { nanoid } from "nanoid";
|
|||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||||
|
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||||
|
|
||||||
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
||||||
protected discoveryUri: string;
|
protected discoveryUri: string;
|
||||||
private configuration: OidcConfigurationCache;
|
private configuration: OidcConfigurationCache;
|
||||||
private jwk: OidcJwkCache;
|
private jwk: OidcJwkCache;
|
||||||
|
private logger: Logger = new Logger(
|
||||||
|
Object.getPrototypeOf(this).constructor.name,
|
||||||
|
);
|
||||||
|
|
||||||
protected constructor(
|
protected constructor(
|
||||||
protected name: string,
|
protected name: string,
|
||||||
@ -112,7 +116,10 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
|||||||
const nonce = await this.cache.get(key);
|
const nonce = await this.cache.get(key);
|
||||||
await this.cache.del(key);
|
await this.cache.del(key);
|
||||||
if (nonce !== idTokenData.nonce) {
|
if (nonce !== idTokenData.nonce) {
|
||||||
throw new BadRequestException("Invalid token");
|
this.logger.error(
|
||||||
|
`Invalid nonce. Expected ${nonce}, but got ${idTokenData.nonce}`,
|
||||||
|
);
|
||||||
|
throw new ErrorPageException("invalid_token");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
import { ConfigService } from "../../config/config.service";
|
||||||
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
|
||||||
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
|
||||||
import { ConfigService } from "../../config/config.service";
|
import { ErrorPageException } from "../exceptions/errorPage.exception";
|
||||||
import fetch from "node-fetch";
|
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
|
||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
||||||
@ -48,12 +49,12 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
|||||||
|
|
||||||
async getUserInfo(token: OAuthToken<GitHubToken>): Promise<OAuthSignInDto> {
|
async getUserInfo(token: OAuthToken<GitHubToken>): Promise<OAuthSignInDto> {
|
||||||
if (!token.scope.includes("user:email")) {
|
if (!token.scope.includes("user:email")) {
|
||||||
throw new BadRequestException("No email permission granted");
|
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
|
||||||
}
|
}
|
||||||
const user = await this.getGitHubUser(token);
|
const user = await this.getGitHubUser(token);
|
||||||
const email = await this.getGitHubEmail(token);
|
const email = await this.getGitHubEmail(token);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
throw new BadRequestException("No email found");
|
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -472,6 +472,8 @@ export default {
|
|||||||
"admin.config.oauth.microsoft-client-secret.description": "Client secret of the Microsoft OAuth app",
|
"admin.config.oauth.microsoft-client-secret.description": "Client secret of the Microsoft OAuth app",
|
||||||
"admin.config.oauth.discord-enabled": "Discord",
|
"admin.config.oauth.discord-enabled": "Discord",
|
||||||
"admin.config.oauth.discord-enabled.description": "Whether Discord login is enabled",
|
"admin.config.oauth.discord-enabled.description": "Whether Discord login is enabled",
|
||||||
|
"admin.config.oauth.discord-limited-guild": "Discord limited server ID",
|
||||||
|
"admin.config.oauth.discord-limited-guild.description": "Limit signing in to users in a specific server. Leave it blank to disable.",
|
||||||
"admin.config.oauth.discord-client-id": "Discord Client ID",
|
"admin.config.oauth.discord-client-id": "Discord Client ID",
|
||||||
"admin.config.oauth.discord-client-id.description": "Client ID of the Discord OAuth app",
|
"admin.config.oauth.discord-client-id.description": "Client ID of the Discord OAuth app",
|
||||||
"admin.config.oauth.discord-client-secret": "Discord Client secret",
|
"admin.config.oauth.discord-client-secret": "Discord Client secret",
|
||||||
@ -496,10 +498,13 @@ export default {
|
|||||||
"error.msg.default": "Something went wrong.",
|
"error.msg.default": "Something went wrong.",
|
||||||
"error.msg.access_denied": "You canceled the authentication process, please try again.",
|
"error.msg.access_denied": "You canceled the authentication process, please try again.",
|
||||||
"error.msg.expired_token": "The authentication process took too long, please try again.",
|
"error.msg.expired_token": "The authentication process took too long, please try again.",
|
||||||
|
"error.msg.invalid_token": "Internal Error",
|
||||||
"error.msg.no_user": "User linked to this {0} account doesn't exist.",
|
"error.msg.no_user": "User linked to this {0} account doesn't exist.",
|
||||||
"error.msg.no_email": "Can't get email address from this {0} account.",
|
"error.msg.no_email": "Can't get email address from this {0} account.",
|
||||||
"error.msg.already_linked": "This {0} account is already linked to another account.",
|
"error.msg.already_linked": "This {0} account is already linked to another account.",
|
||||||
"error.msg.not_linked": "This {0} account haven't linked to any account yet.",
|
"error.msg.not_linked": "This {0} account haven't linked to any account yet.",
|
||||||
|
"error.msg.unverified_account": "This {0} account is unverified, please try again after verification.",
|
||||||
|
"error.msg.discord_guild_permission_denied": "You are not allowed to sign in.",
|
||||||
"error.param.provider_github": "GitHub",
|
"error.param.provider_github": "GitHub",
|
||||||
"error.param.provider_google": "Google",
|
"error.param.provider_google": "Google",
|
||||||
"error.param.provider_microsoft": "Microsoft",
|
"error.param.provider_microsoft": "Microsoft",
|
||||||
|
Loading…
Reference in New Issue
Block a user