Merge pull request #310 from linkwarden/keycloak-integration
Keycloak integration
This commit is contained in:
commit
c73f13a9b0
|
@ -14,7 +14,9 @@ RE_ARCHIVE_LIMIT=
|
|||
SPACES_KEY=
|
||||
SPACES_SECRET=
|
||||
SPACES_ENDPOINT=
|
||||
SPACES_BUCKET_NAME=
|
||||
SPACES_REGION=
|
||||
SPACES_FORCE_PATH_STYLE=
|
||||
|
||||
# SMTP Settings
|
||||
NEXT_PUBLIC_EMAIL_PROVIDER=
|
||||
|
@ -23,3 +25,9 @@ EMAIL_SERVER=
|
|||
|
||||
# Docker postgres settings
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# Keycloak
|
||||
NEXT_PUBLIC_KEYCLOAK_ENABLED=
|
||||
KEYCLOAK_ISSUER=
|
||||
KEYCLOAK_CLIENT_ID=
|
||||
KEYCLOAK_CLIENT_SECRET=
|
||||
|
|
|
@ -21,14 +21,19 @@ export default async function deleteUserById(
|
|||
};
|
||||
}
|
||||
|
||||
// Then, we check if the provided password matches the one stored in the database
|
||||
const isPasswordValid = bcrypt.compareSync(body.password, user.password);
|
||||
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
|
||||
if (!process.env.KEYCLOAK_CLIENT_SECRET) {
|
||||
const isPasswordValid = bcrypt.compareSync(
|
||||
body.password,
|
||||
user.password as string
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return {
|
||||
response: "Invalid credentials.",
|
||||
status: 401, // Unauthorized
|
||||
};
|
||||
if (!isPasswordValid) {
|
||||
return {
|
||||
response: "Invalid credentials.",
|
||||
status: 401, // Unauthorized
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the user and all related data within a transaction
|
||||
|
|
|
@ -14,7 +14,7 @@ export default async function createFile({
|
|||
}) {
|
||||
if (s3Client) {
|
||||
const bucketParams: PutObjectCommandInput = {
|
||||
Bucket: process.env.BUCKET_NAME,
|
||||
Bucket: process.env.SPACES_BUCKET_NAME,
|
||||
Key: filePath,
|
||||
Body: isBase64 ? Buffer.from(data as string, "base64") : data,
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@ import removeFile from "./removeFile";
|
|||
|
||||
export default async function moveFile(from: string, to: string) {
|
||||
if (s3Client) {
|
||||
const Bucket = process.env.BUCKET_NAME;
|
||||
const Bucket = process.env.SPACES_BUCKET_NAME;
|
||||
|
||||
const copyParams = {
|
||||
Bucket: Bucket,
|
||||
|
|
|
@ -20,7 +20,7 @@ export default async function readFile(filePath: string) {
|
|||
|
||||
if (s3Client) {
|
||||
const bucketParams: GetObjectCommandInput = {
|
||||
Bucket: process.env.BUCKET_NAME,
|
||||
Bucket: process.env.SPACES_BUCKET_NAME,
|
||||
Key: filePath,
|
||||
};
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import { PutObjectCommandInput, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
|||
export default async function removeFile({ filePath }: { filePath: string }) {
|
||||
if (s3Client) {
|
||||
const bucketParams: PutObjectCommandInput = {
|
||||
Bucket: process.env.BUCKET_NAME,
|
||||
Bucket: process.env.SPACES_BUCKET_NAME,
|
||||
Key: filePath,
|
||||
};
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ async function emptyS3Directory(bucket: string, dir: string) {
|
|||
export default async function removeFolder({ filePath }: { filePath: string }) {
|
||||
if (s3Client) {
|
||||
try {
|
||||
await emptyS3Directory(process.env.BUCKET_NAME as string, filePath);
|
||||
await emptyS3Directory(process.env.SPACES_BUCKET_NAME as string, filePath);
|
||||
} catch (err) {
|
||||
console.log("Error", err);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ const s3Client: S3 | undefined =
|
|||
process.env.SPACES_KEY &&
|
||||
process.env.SPACES_SECRET
|
||||
? new S3({
|
||||
forcePathStyle: false,
|
||||
forcePathStyle: !!process.env.SPACES_FORCE_PATH_STYLE,
|
||||
endpoint: process.env.SPACES_ENDPOINT,
|
||||
region: process.env.SPACES_REGION,
|
||||
credentials: {
|
||||
|
|
|
@ -9,10 +9,15 @@ import { Adapter } from "next-auth/adapters";
|
|||
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
|
||||
import { Provider } from "next-auth/providers";
|
||||
import verifySubscription from "@/lib/api/verifySubscription";
|
||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||
|
||||
const emailEnabled =
|
||||
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 providers: Provider[] = [
|
||||
|
@ -59,7 +64,7 @@ const providers: Provider[] = [
|
|||
}),
|
||||
];
|
||||
|
||||
if (emailEnabled)
|
||||
if (emailEnabled) {
|
||||
providers.push(
|
||||
EmailProvider({
|
||||
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 = {
|
||||
adapter: PrismaAdapter(prisma) as Adapter,
|
||||
adapter: adapter as Adapter,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
|
@ -85,7 +117,8 @@ export const authOptions: AuthOptions = {
|
|||
callbacks: {
|
||||
async jwt({ token, trigger, user }) {
|
||||
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;
|
||||
},
|
||||
|
|
|
@ -12,6 +12,7 @@ interface FormData {
|
|||
}
|
||||
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
const keycloakEnabled = process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED;
|
||||
|
||||
export default function Login() {
|
||||
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 (
|
||||
<CenteredForm text="Sign in to your account">
|
||||
<form onSubmit={loginUser}>
|
||||
|
@ -102,6 +115,15 @@ export default function Login() {
|
|||
className=" w-full text-center"
|
||||
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 ===
|
||||
"true" ? undefined : (
|
||||
<div className="flex items-baseline gap-1 justify-center">
|
||||
|
|
|
@ -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.");
|
||||
}
|
||||
|
||||
|
@ -78,18 +78,20 @@ export default function Password() {
|
|||
. This action is irreversible!
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-black dark:text-white">
|
||||
Confirm Your Password
|
||||
</p>
|
||||
{process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED !== "true" ? (
|
||||
<div>
|
||||
<p className="mb-2 text-black dark:text-white">
|
||||
Confirm Your Password
|
||||
</p>
|
||||
|
||||
<TextInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••••"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
<TextInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••••"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
{process.env.NEXT_PUBLIC_STRIPE ? (
|
||||
<fieldset className="border rounded-md p-2 border-sky-500">
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "password" DROP NOT NULL;
|
|
@ -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;
|
|
@ -7,6 +7,25 @@ datasource db {
|
|||
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 {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
|
@ -17,7 +36,9 @@ model User {
|
|||
emailVerified DateTime?
|
||||
image String?
|
||||
|
||||
password String
|
||||
accounts Account[]
|
||||
|
||||
password String?
|
||||
|
||||
collections Collection[]
|
||||
tags Tag[]
|
||||
|
@ -101,13 +122,13 @@ model Link {
|
|||
name String
|
||||
url String
|
||||
description String @default("")
|
||||
|
||||
|
||||
pinnedBy User[]
|
||||
|
||||
|
||||
collection Collection @relation(fields: [collectionId], references: [id])
|
||||
collectionId Int
|
||||
tags Tag[]
|
||||
|
||||
|
||||
textContent String?
|
||||
|
||||
screenshotPath String?
|
||||
|
@ -115,7 +136,7 @@ model Link {
|
|||
readabilityPath String?
|
||||
|
||||
lastPreserved DateTime?
|
||||
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt @default(now())
|
||||
}
|
||||
|
@ -135,7 +156,7 @@ model Tag {
|
|||
|
||||
model Subscription {
|
||||
id Int @id @default(autoincrement())
|
||||
active Boolean
|
||||
active Boolean
|
||||
stripeSubscriptionId String @unique
|
||||
currentPeriodStart DateTime
|
||||
currentPeriodEnd DateTime
|
||||
|
|
|
@ -13,8 +13,14 @@ declare global {
|
|||
SPACES_KEY?: string;
|
||||
SPACES_SECRET?: string;
|
||||
SPACES_ENDPOINT?: string;
|
||||
BUCKET_NAME?: string;
|
||||
SPACES_BUCKET_NAME?: 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;
|
||||
EMAIL_FROM?: string;
|
||||
|
|
Ŝarĝante…
Reference in New Issue