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", type: "boolean",
defaultValue: "false", defaultValue: "false",
}, },
"discord-limitedGuild": {
type: "string",
defaultValue: "",
},
"discord-clientId": { "discord-clientId": {
type: "string", type: "string",
defaultValue: "", defaultValue: "",

View File

@ -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");

View File

@ -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(","));
} }

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 { 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[];
}

View File

@ -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 {

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 { 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 {

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.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",