Merge pull request #310 from linkwarden/keycloak-integration

Keycloak integration
This commit is contained in:
Daniel 2023-11-19 17:30:10 +03:30 committed by GitHub
commit c73f13a9b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 157 additions and 35 deletions

View File

@ -14,7 +14,9 @@ RE_ARCHIVE_LIMIT=
SPACES_KEY= SPACES_KEY=
SPACES_SECRET= SPACES_SECRET=
SPACES_ENDPOINT= SPACES_ENDPOINT=
SPACES_BUCKET_NAME=
SPACES_REGION= SPACES_REGION=
SPACES_FORCE_PATH_STYLE=
# SMTP Settings # SMTP Settings
NEXT_PUBLIC_EMAIL_PROVIDER= NEXT_PUBLIC_EMAIL_PROVIDER=
@ -23,3 +25,9 @@ EMAIL_SERVER=
# Docker postgres settings # Docker postgres settings
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
# Keycloak
NEXT_PUBLIC_KEYCLOAK_ENABLED=
KEYCLOAK_ISSUER=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=

View File

@ -21,8 +21,12 @@ export default async function deleteUserById(
}; };
} }
// Then, we check if the provided password matches the one stored in the database // Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
const isPasswordValid = bcrypt.compareSync(body.password, user.password); if (!process.env.KEYCLOAK_CLIENT_SECRET) {
const isPasswordValid = bcrypt.compareSync(
body.password,
user.password as string
);
if (!isPasswordValid) { if (!isPasswordValid) {
return { return {
@ -30,6 +34,7 @@ export default async function deleteUserById(
status: 401, // Unauthorized status: 401, // Unauthorized
}; };
} }
}
// Delete the user and all related data within a transaction // Delete the user and all related data within a transaction
await prisma await prisma

View File

@ -14,7 +14,7 @@ export default async function createFile({
}) { }) {
if (s3Client) { if (s3Client) {
const bucketParams: PutObjectCommandInput = { const bucketParams: PutObjectCommandInput = {
Bucket: process.env.BUCKET_NAME, Bucket: process.env.SPACES_BUCKET_NAME,
Key: filePath, Key: filePath,
Body: isBase64 ? Buffer.from(data as string, "base64") : data, Body: isBase64 ? Buffer.from(data as string, "base64") : data,
}; };

View File

@ -5,7 +5,7 @@ import removeFile from "./removeFile";
export default async function moveFile(from: string, to: string) { export default async function moveFile(from: string, to: string) {
if (s3Client) { if (s3Client) {
const Bucket = process.env.BUCKET_NAME; const Bucket = process.env.SPACES_BUCKET_NAME;
const copyParams = { const copyParams = {
Bucket: Bucket, Bucket: Bucket,

View File

@ -20,7 +20,7 @@ export default async function readFile(filePath: string) {
if (s3Client) { if (s3Client) {
const bucketParams: GetObjectCommandInput = { const bucketParams: GetObjectCommandInput = {
Bucket: process.env.BUCKET_NAME, Bucket: process.env.SPACES_BUCKET_NAME,
Key: filePath, Key: filePath,
}; };

View File

@ -6,7 +6,7 @@ import { PutObjectCommandInput, DeleteObjectCommand } from "@aws-sdk/client-s3";
export default async function removeFile({ filePath }: { filePath: string }) { export default async function removeFile({ filePath }: { filePath: string }) {
if (s3Client) { if (s3Client) {
const bucketParams: PutObjectCommandInput = { const bucketParams: PutObjectCommandInput = {
Bucket: process.env.BUCKET_NAME, Bucket: process.env.SPACES_BUCKET_NAME,
Key: filePath, Key: filePath,
}; };

View File

@ -40,7 +40,7 @@ async function emptyS3Directory(bucket: string, dir: string) {
export default async function removeFolder({ filePath }: { filePath: string }) { export default async function removeFolder({ filePath }: { filePath: string }) {
if (s3Client) { if (s3Client) {
try { try {
await emptyS3Directory(process.env.BUCKET_NAME as string, filePath); await emptyS3Directory(process.env.SPACES_BUCKET_NAME as string, filePath);
} catch (err) { } catch (err) {
console.log("Error", err); console.log("Error", err);
} }

View File

@ -6,7 +6,7 @@ const s3Client: S3 | undefined =
process.env.SPACES_KEY && process.env.SPACES_KEY &&
process.env.SPACES_SECRET process.env.SPACES_SECRET
? new S3({ ? new S3({
forcePathStyle: false, forcePathStyle: !!process.env.SPACES_FORCE_PATH_STYLE,
endpoint: process.env.SPACES_ENDPOINT, endpoint: process.env.SPACES_ENDPOINT,
region: process.env.SPACES_REGION, region: process.env.SPACES_REGION,
credentials: { credentials: {

View File

@ -9,10 +9,15 @@ import { Adapter } from "next-auth/adapters";
import sendVerificationRequest from "@/lib/api/sendVerificationRequest"; import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
import { Provider } from "next-auth/providers"; import { Provider } from "next-auth/providers";
import verifySubscription from "@/lib/api/verifySubscription"; import verifySubscription from "@/lib/api/verifySubscription";
import KeycloakProvider from "next-auth/providers/keycloak";
const emailEnabled = const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
const keycloakEnabled = process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === "true";
const adapter = PrismaAdapter(prisma);
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const providers: Provider[] = [ const providers: Provider[] = [
@ -59,7 +64,7 @@ const providers: Provider[] = [
}), }),
]; ];
if (emailEnabled) if (emailEnabled) {
providers.push( providers.push(
EmailProvider({ EmailProvider({
server: process.env.EMAIL_SERVER, server: process.env.EMAIL_SERVER,
@ -70,9 +75,36 @@ if (emailEnabled)
}, },
}) })
); );
}
if (keycloakEnabled) {
providers.push(
KeycloakProvider({
id: "keycloak",
name: "Keycloak",
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER,
profile: (profile) => {
return {
id: profile.sub,
username: profile.preferred_username,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: profile.picture,
};
},
})
);
const _linkAccount = adapter.linkAccount;
adapter.linkAccount = (account) => {
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
return _linkAccount ? _linkAccount(data) : undefined;
};
}
export const authOptions: AuthOptions = { export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma) as Adapter, adapter: adapter as Adapter,
session: { session: {
strategy: "jwt", strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days maxAge: 30 * 24 * 60 * 60, // 30 days
@ -85,7 +117,8 @@ export const authOptions: AuthOptions = {
callbacks: { callbacks: {
async jwt({ token, trigger, user }) { async jwt({ token, trigger, user }) {
token.sub = token.sub ? Number(token.sub) : undefined; token.sub = token.sub ? Number(token.sub) : undefined;
if (trigger === "signIn") token.id = user?.id as number; if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
return token; return token;
}, },

View File

@ -12,6 +12,7 @@ interface FormData {
} }
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
const keycloakEnabled = process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED;
export default function Login() { export default function Login() {
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
@ -47,6 +48,18 @@ export default function Login() {
} }
} }
async function loginUserKeycloak() {
setSubmitLoader(true);
const load = toast.loading("Authenticating...");
const res = await signIn("keycloak", {});
toast.dismiss(load);
setSubmitLoader(false);
}
return ( return (
<CenteredForm text="Sign in to your account"> <CenteredForm text="Sign in to your account">
<form onSubmit={loginUser}> <form onSubmit={loginUser}>
@ -102,6 +115,15 @@ export default function Login() {
className=" w-full text-center" className=" w-full text-center"
loading={submitLoader} loading={submitLoader}
/> />
{process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === "true" ? (
<SubmitButton
type="button"
onClick={loginUserKeycloak}
label="Sign in with Keycloak"
className=" w-full text-center"
loading={submitLoader}
/>
) : undefined}
{process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === {process.env.NEXT_PUBLIC_DISABLE_REGISTRATION ===
"true" ? undefined : ( "true" ? undefined : (
<div className="flex items-baseline gap-1 justify-center"> <div className="flex items-baseline gap-1 justify-center">

View File

@ -25,7 +25,7 @@ export default function Password() {
}, },
}; };
if (password == "") { if (process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED !== "true" && password == "") {
return toast.error("Please fill the required fields."); return toast.error("Please fill the required fields.");
} }
@ -78,6 +78,7 @@ export default function Password() {
. This action is irreversible! . This action is irreversible!
</p> </p>
{process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED !== "true" ? (
<div> <div>
<p className="mb-2 text-black dark:text-white"> <p className="mb-2 text-black dark:text-white">
Confirm Your Password Confirm Your Password
@ -90,6 +91,7 @@ export default function Password() {
type="password" type="password"
/> />
</div> </div>
) : undefined}
{process.env.NEXT_PUBLIC_STRIPE ? ( {process.env.NEXT_PUBLIC_STRIPE ? (
<fieldset className="border rounded-md p-2 border-sky-500"> <fieldset className="border rounded-md p-2 border-sky-500">

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "password" DROP NOT NULL;

View File

@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -7,6 +7,25 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model Account {
id String @id @default(cuid())
userId Int
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
@ -17,7 +36,9 @@ model User {
emailVerified DateTime? emailVerified DateTime?
image String? image String?
password String accounts Account[]
password String?
collections Collection[] collections Collection[]
tags Tag[] tags Tag[]

View File

@ -13,8 +13,14 @@ declare global {
SPACES_KEY?: string; SPACES_KEY?: string;
SPACES_SECRET?: string; SPACES_SECRET?: string;
SPACES_ENDPOINT?: string; SPACES_ENDPOINT?: string;
BUCKET_NAME?: string; SPACES_BUCKET_NAME?: string;
SPACES_REGION?: string; SPACES_REGION?: string;
SPACES_FORCE_PATH_STYLE?: string;
NEXT_PUBLIC_KEYCLOAK_ENABLED?: string;
KEYCLOAK_ISSUER?: string;
KEYCLOAK_CLIENT_ID?: string;
KEYCLOAK_CLIENT_SECRET?: string;
NEXT_PUBLIC_EMAIL_PROVIDER?: string; NEXT_PUBLIC_EMAIL_PROVIDER?: string;
EMAIL_FROM?: string; EMAIL_FROM?: string;