1
0
mirror of https://github.com/stonith404/pingvin-share.git synced 2024-09-28 15:50:10 +02: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:
Qing Fu 2023-12-01 05:41:06 +08:00 committed by GitHub
parent d9a9523c9a
commit 5f94c7295a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 89 additions and 21 deletions

View File

@ -180,6 +180,10 @@ const configVariables: ConfigVariables = {
type: "boolean",
defaultValue: "false",
},
"discord-limitedGuild": {
type: "string",
defaultValue: "",
},
"discord-clientId": {
type: "string",
defaultValue: "",
@ -262,8 +266,8 @@ async function migrateConfigVariables() {
for (const existingConfigVariable of existingConfigVariables) {
const configVariable =
configVariables[existingConfigVariable.category]?.[
existingConfigVariable.name
];
existingConfigVariable.name
];
if (!configVariable) {
await prisma.config.delete({
where: {

View File

@ -7,7 +7,7 @@ export class ErrorPageException extends Error {
*/
constructor(
public readonly key: string = "default",
public readonly redirect: string = "/",
public readonly redirect?: string,
public readonly params?: string[],
) {
super("error");

View File

@ -9,14 +9,27 @@ export class ErrorPageExceptionFilter implements ExceptionFilter {
constructor(private config: ConfigService) {}
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 response = ctx.getResponse();
const url = new URL(`${this.config.get("general.appUrl")}/error`);
url.searchParams.set("redirect", exception.redirect);
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) {
url.searchParams.set("params", exception.params.join(","));
}

View File

@ -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 { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { ConfigService } from "../../config/config.service";
import { BadRequestException, Injectable } from "@nestjs/common";
import fetch from "node-fetch";
import { ErrorPageException } from "../exceptions/errorPage.exception";
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
@Injectable()
export class DiscordProvider implements OAuthProvider<DiscordToken> {
constructor(private config: ConfigService) {}
getAuthEndpoint(state: string): Promise<string> {
let scope = "identify email";
if (this.config.get("oauth.discord-limitedGuild")) {
scope += " guilds";
}
return Promise.resolve(
"https://discord.com/api/oauth2/authorize?" +
new URLSearchParams({
@ -17,8 +21,8 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
redirect_uri:
this.config.get("general.appUrl") + "/api/oauth/callback/discord",
response_type: "code",
state: state,
scope: "identify email",
state,
scope,
}).toString(),
);
}
@ -69,7 +73,14 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
});
const user = (await res.json()) as DiscordUser;
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 {
@ -79,6 +90,24 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
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 {
@ -96,3 +125,12 @@ export interface DiscordUser {
email: string;
verified: boolean;
}
export interface DiscordPartialGuild {
id: string;
name: string;
icon: string;
owner: boolean;
permissions: string;
features: string[];
}

View File

@ -1,4 +1,4 @@
import { BadRequestException } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import fetch from "node-fetch";
import { ConfigService } from "../../config/config.service";
import { JwtService } from "@nestjs/jwt";
@ -7,11 +7,15 @@ import { nanoid } from "nanoid";
import { OAuthCallbackDto } from "../dto/oauthCallback.dto";
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
import { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { ErrorPageException } from "../exceptions/errorPage.exception";
export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
protected discoveryUri: string;
private configuration: OidcConfigurationCache;
private jwk: OidcJwkCache;
private logger: Logger = new Logger(
Object.getPrototypeOf(this).constructor.name,
);
protected constructor(
protected name: string,
@ -112,7 +116,10 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
const nonce = await this.cache.get(key);
await this.cache.del(key);
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 {

View File

@ -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 { OAuthSignInDto } from "../dto/oauthSignIn.dto";
import { ConfigService } from "../../config/config.service";
import fetch from "node-fetch";
import { BadRequestException, Injectable } from "@nestjs/common";
import { ErrorPageException } from "../exceptions/errorPage.exception";
import { OAuthProvider, OAuthToken } from "./oauthProvider.interface";
@Injectable()
export class GitHubProvider implements OAuthProvider<GitHubToken> {
@ -48,12 +49,12 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
async getUserInfo(token: OAuthToken<GitHubToken>): Promise<OAuthSignInDto> {
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 email = await this.getGitHubEmail(token);
if (!email) {
throw new BadRequestException("No email found");
throw new ErrorPageException("no_email", undefined, ["provider_github"]);
}
return {

View File

@ -472,6 +472,8 @@ export default {
"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.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.description": "Client ID of the Discord OAuth app",
"admin.config.oauth.discord-client-secret": "Discord Client secret",
@ -496,10 +498,13 @@ export default {
"error.msg.default": "Something went wrong.",
"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.invalid_token": "Internal Error",
"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.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.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_google": "Google",
"error.param.provider_microsoft": "Microsoft",