mirror of
https://github.com/stonith404/pingvin-share.git
synced 2024-11-15 11:50:34 +01:00
feat(oauth): Add option to logout from OpenID Connect provider
* Fixes #598 Signed-off-by: Marvin A. Ruder <signed@mruder.dev>
This commit is contained in:
parent
104cc06145
commit
2b3ce3ffd2
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RefreshToken" ADD COLUMN "oauthIDToken" TEXT;
|
@ -40,6 +40,8 @@ model RefreshToken {
|
|||||||
|
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
oauthIDToken String? // prefixed with the ID of the issuing OAuth provider, separated by a colon
|
||||||
}
|
}
|
||||||
|
|
||||||
model LoginToken {
|
model LoginToken {
|
||||||
|
@ -275,6 +275,10 @@ const configVariables: ConfigVariables = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
|
"oidc-signOut": {
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: "false",
|
||||||
|
},
|
||||||
"oidc-usernameClaim": {
|
"oidc-usernameClaim": {
|
||||||
type: "string",
|
type: "string",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
|
@ -172,10 +172,10 @@ export class AuthController {
|
|||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
await this.authService.signOut(request.cookies.access_token);
|
const redirectURI = await this.authService.signOut(request.cookies.access_token);
|
||||||
|
|
||||||
const isSecure = this.config.get("general.appUrl").startsWith("https");
|
const isSecure = this.config.get("general.appUrl").startsWith("https");
|
||||||
response.cookie("access_token", "accessToken", {
|
response.cookie("access_token", "", {
|
||||||
maxAge: -1,
|
maxAge: -1,
|
||||||
secure: isSecure,
|
secure: isSecure,
|
||||||
});
|
});
|
||||||
@ -185,6 +185,10 @@ export class AuthController {
|
|||||||
maxAge: -1,
|
maxAge: -1,
|
||||||
secure: isSecure,
|
secure: isSecure,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof redirectURI === "string") {
|
||||||
|
return { redirectURI: redirectURI.toString() };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("totp/enable")
|
@Post("totp/enable")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { EmailModule } from "src/email/email.module";
|
import { EmailModule } from "src/email/email.module";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
@ -7,6 +7,7 @@ import { AuthTotpService } from "./authTotp.service";
|
|||||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||||
import { LdapService } from "./ldap.service";
|
import { LdapService } from "./ldap.service";
|
||||||
import { UserModule } from "../user/user.module";
|
import { UserModule } from "../user/user.module";
|
||||||
|
import { OAuthModule } from "../oauth/oauth.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -14,6 +15,7 @@ import { UserModule } from "../user/user.module";
|
|||||||
global: true,
|
global: true,
|
||||||
}),
|
}),
|
||||||
EmailModule,
|
EmailModule,
|
||||||
|
forwardRef(() => OAuthModule),
|
||||||
UserModule,
|
UserModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
@ -17,7 +19,8 @@ import { PrismaService } from "src/prisma/prisma.service";
|
|||||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||||
import { LdapService } from "./ldap.service";
|
import { LdapService } from "./ldap.service";
|
||||||
import { inspect } from "util";
|
import { GenericOidcProvider } from "../oauth/provider/genericOidc.provider";
|
||||||
|
import { OAuthService } from "../oauth/oauth.service";
|
||||||
import { UserSevice } from "../user/user.service";
|
import { UserSevice } from "../user/user.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -29,6 +32,7 @@ export class AuthService {
|
|||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
private ldapService: LdapService,
|
private ldapService: LdapService,
|
||||||
private userService: UserSevice,
|
private userService: UserSevice,
|
||||||
|
@Inject(forwardRef(() => OAuthService)) private oAuthService: OAuthService,
|
||||||
) {}
|
) {}
|
||||||
private readonly logger = new Logger(AuthService.name);
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
@ -113,12 +117,12 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException("Wrong email or password");
|
throw new UnauthorizedException("Wrong email or password");
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateToken(user: User, isOAuth = false) {
|
async generateToken(user: User, oauth?: { idToken?: string }) {
|
||||||
// TODO: Make all old loginTokens invalid when a new one is created
|
// TODO: Make all old loginTokens invalid when a new one is created
|
||||||
// Check if the user has TOTP enabled
|
// Check if the user has TOTP enabled
|
||||||
if (
|
if (
|
||||||
user.totpVerified &&
|
user.totpVerified &&
|
||||||
!(isOAuth && this.config.get("oauth.ignoreTotp"))
|
!(oauth && this.config.get("oauth.ignoreTotp"))
|
||||||
) {
|
) {
|
||||||
const loginToken = await this.createLoginToken(user.id);
|
const loginToken = await this.createLoginToken(user.id);
|
||||||
|
|
||||||
@ -127,6 +131,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
const { refreshToken, refreshTokenId } = await this.createRefreshToken(
|
||||||
user.id,
|
user.id,
|
||||||
|
oauth?.idToken,
|
||||||
);
|
);
|
||||||
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
const accessToken = await this.createAccessToken(user, refreshTokenId);
|
||||||
|
|
||||||
@ -225,12 +230,39 @@ export class AuthService {
|
|||||||
}) || {};
|
}) || {};
|
||||||
|
|
||||||
if (refreshTokenId) {
|
if (refreshTokenId) {
|
||||||
|
const oauthIDToken = await this.prisma.refreshToken
|
||||||
|
.findFirst({ select: { oauthIDToken: true }, where: { id: refreshTokenId } })
|
||||||
|
.then((refreshToken) => refreshToken?.oauthIDToken)
|
||||||
|
.catch((e) => {
|
||||||
|
// Ignore error if refresh token doesn't exist
|
||||||
|
if (e.code != "P2025") throw e;
|
||||||
|
});
|
||||||
await this.prisma.refreshToken
|
await this.prisma.refreshToken
|
||||||
.delete({ where: { id: refreshTokenId } })
|
.delete({ where: { id: refreshTokenId } })
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
// Ignore error if refresh token doesn't exist
|
// Ignore error if refresh token doesn't exist
|
||||||
if (e.code != "P2025") throw e;
|
if (e.code != "P2025") throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof oauthIDToken === "string") {
|
||||||
|
const [providerName, idTokenHint] = oauthIDToken.split(":");
|
||||||
|
const provider = this.oAuthService.availableProviders()[providerName];
|
||||||
|
let signOutFromProviderSupportedAndActivated = false;
|
||||||
|
try {
|
||||||
|
signOutFromProviderSupportedAndActivated = this.config.get(`oauth.${providerName}-signOut`);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore error if the provider is not supported or if the provider sign out is not activated
|
||||||
|
}
|
||||||
|
if (provider instanceof GenericOidcProvider && signOutFromProviderSupportedAndActivated) {
|
||||||
|
const configuration = await provider.getConfiguration();
|
||||||
|
if (configuration.frontchannel_logout_supported && URL.canParse(configuration.end_session_endpoint)) {
|
||||||
|
const redirectURI = new URL(configuration.end_session_endpoint);
|
||||||
|
redirectURI.searchParams.append("id_token_hint", idTokenHint);
|
||||||
|
redirectURI.searchParams.append("client_id", this.config.get(`oauth.${providerName}-clientId`));
|
||||||
|
return redirectURI.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,13 +281,14 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRefreshToken(userId: string) {
|
async createRefreshToken(userId: string, idToken?: string) {
|
||||||
const { id, token } = await this.prisma.refreshToken.create({
|
const { id, token } = await this.prisma.refreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
expiresAt: moment()
|
expiresAt: moment()
|
||||||
.add(this.config.get("general.sessionDuration"), "hours")
|
.add(this.config.get("general.sessionDuration"), "hours")
|
||||||
.toDate(),
|
.toDate(),
|
||||||
|
oauthIDToken: idToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -4,4 +4,5 @@ export interface OAuthSignInDto {
|
|||||||
providerUsername: string;
|
providerUsername: string;
|
||||||
email: string;
|
email: string;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
idToken?: string;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { OAuthController } from "./oauth.controller";
|
import { OAuthController } from "./oauth.controller";
|
||||||
import { OAuthService } from "./oauth.service";
|
import { OAuthService } from "./oauth.service";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
@ -51,6 +51,7 @@ import { MicrosoftProvider } from "./provider/microsoft.provider";
|
|||||||
inject: ["OAUTH_PROVIDERS"],
|
inject: ["OAUTH_PROVIDERS"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
imports: [AuthModule],
|
imports: [forwardRef(() => AuthModule)],
|
||||||
|
exports: [OAuthService],
|
||||||
})
|
})
|
||||||
export class OAuthModule {}
|
export class OAuthModule {}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { AuthService } from "../auth/auth.service";
|
import { AuthService } from "../auth/auth.service";
|
||||||
@ -6,14 +6,16 @@ import { ConfigService } from "../config/config.service";
|
|||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
|
import { OAuthSignInDto } from "./dto/oauthSignIn.dto";
|
||||||
import { ErrorPageException } from "./exceptions/errorPage.exception";
|
import { ErrorPageException } from "./exceptions/errorPage.exception";
|
||||||
|
import { OAuthProvider } from "./provider/oauthProvider.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthService {
|
export class OAuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private auth: AuthService,
|
@Inject(forwardRef(() => AuthService)) private auth: AuthService,
|
||||||
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
@Inject("OAUTH_PLATFORMS") private platforms: string[],
|
||||||
|
@Inject("OAUTH_PROVIDERS") private oAuthProviders: Record<string, OAuthProvider<unknown>>,
|
||||||
) {}
|
) {}
|
||||||
private readonly logger = new Logger(OAuthService.name);
|
private readonly logger = new Logger(OAuthService.name);
|
||||||
|
|
||||||
@ -27,6 +29,16 @@ export class OAuthService {
|
|||||||
.map(([platform, _]) => platform);
|
.map(([platform, _]) => platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
availableProviders(): Record<string, OAuthProvider<unknown>> {
|
||||||
|
return Object.fromEntries(Object.entries(this.oAuthProviders)
|
||||||
|
.map(([providerName, provider]) => [
|
||||||
|
[providerName, provider],
|
||||||
|
this.config.get(`oauth.${providerName}-enabled`),
|
||||||
|
])
|
||||||
|
.filter(([_, enabled]) => enabled)
|
||||||
|
.map(([provider, _]) => provider));
|
||||||
|
}
|
||||||
|
|
||||||
async status(user: User) {
|
async status(user: User) {
|
||||||
const oauthUsers = await this.prisma.oAuthUser.findMany({
|
const oauthUsers = await this.prisma.oAuthUser.findMany({
|
||||||
select: {
|
select: {
|
||||||
@ -55,7 +67,7 @@ export class OAuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
this.logger.log(`Successful login for user ${user.email} from IP ${ip}`);
|
||||||
return this.auth.generateToken(updatedUser, true);
|
return this.auth.generateToken(updatedUser, { idToken: user.idToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.signUp(user, ip);
|
return this.signUp(user, ip);
|
||||||
@ -156,7 +168,7 @@ export class OAuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.updateIsAdmin(user);
|
await this.updateIsAdmin(user);
|
||||||
return this.auth.generateToken(existingUser, true);
|
return this.auth.generateToken(existingUser, { idToken: user.idToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.auth.signUp(
|
const result = await this.auth.signUp(
|
||||||
|
@ -91,6 +91,7 @@ export class DiscordProvider implements OAuthProvider<DiscordToken> {
|
|||||||
providerId: user.id,
|
providerId: user.id,
|
||||||
providerUsername: user.global_name ?? user.username,
|
providerUsername: user.global_name ?? user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
idToken: `discord:${token.idToken}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -197,6 +197,7 @@ export abstract class GenericOidcProvider implements OAuthProvider<OidcToken> {
|
|||||||
providerId: idTokenData.sub,
|
providerId: idTokenData.sub,
|
||||||
providerUsername: username,
|
providerUsername: username,
|
||||||
...(isAdmin !== undefined && { isAdmin }),
|
...(isAdmin !== undefined && { isAdmin }),
|
||||||
|
idToken: `${this.name}:${token.idToken}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,6 +252,8 @@ export interface OidcConfiguration {
|
|||||||
id_token_signing_alg_values_supported: string[];
|
id_token_signing_alg_values_supported: string[];
|
||||||
scopes_supported?: string[];
|
scopes_supported?: string[];
|
||||||
claims_supported?: string[];
|
claims_supported?: string[];
|
||||||
|
frontchannel_logout_supported?: boolean;
|
||||||
|
end_session_endpoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OidcJwk {
|
export interface OidcJwk {
|
||||||
|
@ -61,6 +61,7 @@ export class GitHubProvider implements OAuthProvider<GitHubToken> {
|
|||||||
providerId: user.id.toString(),
|
providerId: user.id.toString(),
|
||||||
providerUsername: user.name ?? user.login,
|
providerUsername: user.name ?? user.login,
|
||||||
email,
|
email,
|
||||||
|
idToken: `github:${token.idToken}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -392,6 +392,8 @@ export default {
|
|||||||
"admin.config.oauth.oidc-enabled.description": "OpenID Connect Anmeldung erlaubt",
|
"admin.config.oauth.oidc-enabled.description": "OpenID Connect Anmeldung erlaubt",
|
||||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Verbindung Discovery URL",
|
"admin.config.oauth.oidc-discovery-uri": "OpenID Verbindung Discovery URL",
|
||||||
"admin.config.oauth.oidc-discovery-uri.description": "Discovery-URL der OpenID OAuth App",
|
"admin.config.oauth.oidc-discovery-uri.description": "Discovery-URL der OpenID OAuth App",
|
||||||
|
"admin.config.oauth.oidc-sign-out": "Abmelden von OpenID Connect",
|
||||||
|
"admin.config.oauth.oidc-sign-out.description": "Wenn aktiviert, wird der Benutzer mit der „Abmelden“-Schaltfläche vom OpenID-Connect-Provider abgemeldet.",
|
||||||
"admin.config.oauth.oidc-username-claim": "OpenID Connect Benutzername anfordern",
|
"admin.config.oauth.oidc-username-claim": "OpenID Connect Benutzername anfordern",
|
||||||
"admin.config.oauth.oidc-username-claim.description": "Benutzername im OpenID Token. Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
|
"admin.config.oauth.oidc-username-claim.description": "Benutzername im OpenID Token. Leer lassen, wenn du nicht weißt, was diese Konfiguration bedeutet.",
|
||||||
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
|
"admin.config.oauth.oidc-role-path": "Path to roles in OpenID Connect token",
|
||||||
|
@ -547,6 +547,9 @@ export default {
|
|||||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
|
"admin.config.oauth.oidc-discovery-uri": "OpenID Connect Discovery URI",
|
||||||
"admin.config.oauth.oidc-discovery-uri.description":
|
"admin.config.oauth.oidc-discovery-uri.description":
|
||||||
"Discovery URI of the OpenID Connect OAuth app",
|
"Discovery URI of the OpenID Connect OAuth app",
|
||||||
|
"admin.config.oauth.oidc-sign-out": "Sign out from OpenID Connect",
|
||||||
|
"admin.config.oauth.oidc-sign-out.description":
|
||||||
|
"Whether the “Sign out” button will sign out from the OpenID Connect provider",
|
||||||
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
|
"admin.config.oauth.oidc-username-claim": "OpenID Connect username claim",
|
||||||
"admin.config.oauth.oidc-username-claim.description":
|
"admin.config.oauth.oidc-username-claim.description":
|
||||||
"Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.",
|
"Username claim in OpenID Connect ID token. Leave it blank if you don't know what this config is.",
|
||||||
|
@ -29,8 +29,10 @@ const signUp = async (email: string, username: string, password: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
await api.post("/auth/signOut");
|
const response = await api.post("/auth/signOut");
|
||||||
window.location.reload();
|
|
||||||
|
if (URL.canParse(response.data?.redirectURI)) window.location.href = response.data.redirectURI;
|
||||||
|
else window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshAccessToken = async () => {
|
const refreshAccessToken = async () => {
|
||||||
|
Loading…
Reference in New Issue
Block a user