Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e64170551 | |||
| 29892a915d | |||
| 1340f2029a | |||
| ff6886e309 | |||
| 0d09e0d44f | |||
| 7252456dd4 | |||
| fe41df4244 | |||
| 49e9fa8bc6 | |||
| a828c62e71 | |||
| 552c99bfe7 | |||
| d7a9fb6a8c | |||
| 15b7b30b08 | |||
| d709f6657a | |||
| 0ca7a97d26 |
10
.env
Normal file
10
.env
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
DUMMY_HTML_DIR=./development-test-data-dir/
|
||||||
|
ASSETS_DOMAIN=assets.suyono.me
|
||||||
|
MYSQL_HOST=db.host
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=dbuser
|
||||||
|
MYSQL_PASSWORD=dbpasswd
|
||||||
|
MYSQL_DATABASE=dbname
|
||||||
|
MYSQL_SSL_CA=/path/to/ca.file
|
||||||
|
MYSQL_SSL_CERT=/path/to/cert.file
|
||||||
|
MYSQL_SSL_KEY=/path/to/key.file
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,7 +27,8 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env.development
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
5
.idea/dataSources.local.xml
generated
5
.idea/dataSources.local.xml
generated
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="dataSourceStorageLocal" created-in="WS-241.15989.105">
|
<component name="dataSourceStorageLocal" created-in="WS-241.17011.90">
|
||||||
<data-source name="devblog@mysql-server" uuid="32913bc6-cafd-416c-b070-eee7c73cf755">
|
<data-source name="devblog@mysql-server" uuid="32913bc6-cafd-416c-b070-eee7c73cf755">
|
||||||
<database-info product="MySQL" version="8.3.0" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-java-8.0.25 (Revision: 08be9e9b4cba6aa115f9b27b215887af40b159e0)" dbms="MYSQL" exact-version="8.3.0" exact-driver-version="8.0">
|
<database-info product="MySQL" version="8.3.0" jdbc-version="4.2" driver-name="MySQL Connector/J" driver-version="mysql-connector-j-8.2.0 (Revision: 06a1f724497fd81c6a659131fda822c9e5085b6c)" dbms="MYSQL" exact-version="8.3.0" exact-driver-version="8.2">
|
||||||
<extra-name-characters>#@</extra-name-characters>
|
<extra-name-characters>#@</extra-name-characters>
|
||||||
<identifier-quote-string>`</identifier-quote-string>
|
<identifier-quote-string>`</identifier-quote-string>
|
||||||
|
<jdbc-catalog-is-schema>true</jdbc-catalog-is-schema>
|
||||||
</database-info>
|
</database-info>
|
||||||
<case-sensitivity plain-identifiers="exact" quoted-identifiers="exact" />
|
<case-sensitivity plain-identifiers="exact" quoted-identifiers="exact" />
|
||||||
<secret-storage>master_key</secret-storage>
|
<secret-storage>master_key</secret-storage>
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
nodejs 20.12.2
|
|
||||||
pnpm 9.0.6
|
|
||||||
32
app/fonts.ts
32
app/fonts.ts
@@ -1,23 +1,41 @@
|
|||||||
import { Raleway, Syne, Questrial, Nunito_Sans } from "next/font/google";
|
import {
|
||||||
|
Raleway,
|
||||||
|
Syne,
|
||||||
|
Questrial,
|
||||||
|
Roboto,
|
||||||
|
Nunito_Sans,
|
||||||
|
Open_Sans
|
||||||
|
} from "next/font/google";
|
||||||
|
|
||||||
export const raleway = Raleway({
|
export const raleway = Raleway({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
})
|
});
|
||||||
|
|
||||||
export const syne = Syne({
|
export const syne = Syne({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
})
|
});
|
||||||
|
|
||||||
export const questrial = Questrial({
|
export const questrial = Questrial({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
weight: ['400'],
|
weight: ['400'],
|
||||||
})
|
});
|
||||||
|
|
||||||
export const nunito_sans = Nunito_Sans({
|
export const roboto = Roboto({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['400'],
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const openSans = Open_Sans({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
export const nunito = Nunito_Sans({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['300'],
|
||||||
|
display: "swap",
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss/base";
|
||||||
@tailwind components;
|
@import "tailwindcss/components";
|
||||||
@tailwind utilities;
|
@import "tailwindcss/utilities";
|
||||||
|
|
||||||
|
@import "highlight.js/styles/base16/atelier-forest-light.min.css";
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
|
||||||
import BlogHeader from "@/components/blogHeader";
|
import BlogHeader from "@/components/blogHeader";
|
||||||
import BlogFooter from "@/components/blogFooter";
|
import BlogFooter from "@/components/blogFooter";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Create Next App",
|
||||||
description: "Generated by create next app",
|
description: "Generated by create next app",
|
||||||
@@ -19,7 +16,7 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>
|
<body>
|
||||||
<div className={`flex flex-col bg-white`}>
|
<div className={`flex flex-col bg-white`}>
|
||||||
<BlogHeader />
|
<BlogHeader />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
7
app/mark/page.tsx
Normal file
7
app/mark/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { MarkPostString } from '@/components/dummyPost';
|
||||||
|
import { content } from '@/components/post';
|
||||||
|
|
||||||
|
export default async function Mark() {
|
||||||
|
let postContent = await MarkPostString();
|
||||||
|
return content(postContent);
|
||||||
|
}
|
||||||
38
app/page.tsx
38
app/page.tsx
@@ -1,13 +1,14 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import { getAssetsDomain } from "@/backend/env"
|
||||||
import { raleway, syne, questrial } from "@/app/fonts";
|
import { raleway, syne, questrial } from "@/app/fonts";
|
||||||
|
import { HomePostList } from "@/components/homePostList";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col`}>
|
<div className={`flex flex-col`}>
|
||||||
<div className={`grid grid-rows-1 grid-cols-1 justify-items-center`}>
|
<div className={`grid grid-rows-1 grid-cols-1 justify-items-center`}>
|
||||||
<Image
|
<Image
|
||||||
src={`https://assets.suyono.me/placeholder.webp`}
|
src={`https://${getAssetsDomain()}/placeholder.webp`}
|
||||||
alt={`blog cover`}
|
alt={`blog cover`}
|
||||||
className={`object-cover col-start-1 row-start-1 w-screen h-192 z-0`}
|
className={`object-cover col-start-1 row-start-1 w-screen h-192 z-0`}
|
||||||
width={1581}
|
width={1581}
|
||||||
@@ -24,38 +25,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex flex-row justify-center my-8`}>
|
<HomePostList className={`flex flex-col mx-auto my-8`} />
|
||||||
<div className={`border border-slate-100 flex flex-col`}>
|
|
||||||
<Link
|
|
||||||
href="/post/nginx-ssl-client-certificate-verification-manage-access-to-a-site"
|
|
||||||
className={`flex flex-row max-w-4xl items-center`}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="https://assets.suyono.me/pthumb.webp"
|
|
||||||
alt="post thumbnail"
|
|
||||||
width={454}
|
|
||||||
height={341}
|
|
||||||
/>
|
|
||||||
<div className={`flex flex-col mx-10`}>
|
|
||||||
<p className={`${syne.className} text-2xl`}>
|
|
||||||
Nginx + SSL Client Certificate Verification: Manage Access to a
|
|
||||||
site
|
|
||||||
</p>
|
|
||||||
<p className={`${questrial.className} line-clamp-3 mt-4`}>
|
|
||||||
Access control is a fundamental part of security. Most entities
|
|
||||||
rely on the combination of username and password, sometimes with
|
|
||||||
additional multi-factor authentication to improve security. Some
|
|
||||||
entities also use the SSL client certificate verification to
|
|
||||||
manage access to specific resources. One of the use cases where
|
|
||||||
SSL client certificate verification fits perfectly is managing
|
|
||||||
access to internet-facing development or staging servers. In
|
|
||||||
this post, I'll share how to set up the certificates and
|
|
||||||
configure nginx to verify users based on their certificates.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`flex flex-row bg-teal-50 justify-center`}>
|
<div className={`flex flex-row bg-teal-50 justify-center`}>
|
||||||
<div className={`max-w-4xl py-28 px-10`}>
|
<div className={`max-w-4xl py-28 px-10`}>
|
||||||
<p className={`text-3xl ${raleway.className}`}>Hi There</p>
|
<p className={`text-3xl ${raleway.className}`}>Hi There</p>
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import {ReactElement} from "react";
|
|
||||||
import {domToReact, Element} from "html-react-parser";
|
|
||||||
import {nunito_sans, raleway} from "@/app/fonts";
|
|
||||||
import {Code} from "bright";
|
|
||||||
import {mark} from "@/bright-extension/mark";
|
|
||||||
|
|
||||||
export type nodeHandlerResult = {match :boolean, element? :ReactElement}
|
|
||||||
export type nodeHandler = (node :Element) => nodeHandlerResult
|
|
||||||
|
|
||||||
function h1(node: Element): nodeHandlerResult {
|
|
||||||
if (node.name === "h1") {
|
|
||||||
if (node.attribs.class === "title") {
|
|
||||||
return {
|
|
||||||
match: true,
|
|
||||||
element: (
|
|
||||||
<h1 className={`${raleway.className} mx-auto w-224 text-4xl`}>
|
|
||||||
{domToReact(node.children)}
|
|
||||||
</h1>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
match: true,
|
|
||||||
element: (
|
|
||||||
<h1 className={`${raleway.className} mx-auto w-224 text-3xl`}>
|
|
||||||
{domToReact(node.children)}
|
|
||||||
</h1>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { match: false};
|
|
||||||
}
|
|
||||||
|
|
||||||
function h2(node: Element): nodeHandlerResult {
|
|
||||||
if (node.name === "h2") {
|
|
||||||
return {
|
|
||||||
match: true,
|
|
||||||
element: (
|
|
||||||
<h1 className={`${raleway.className} mx-auto w-224 text-2xl`}>
|
|
||||||
{domToReact(node.children)}
|
|
||||||
</h1>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {match: false};
|
|
||||||
}
|
|
||||||
|
|
||||||
function h3(node: Element): nodeHandlerResult {
|
|
||||||
if (node.name === "h3") {
|
|
||||||
return {
|
|
||||||
match: true,
|
|
||||||
element: (
|
|
||||||
<h1 className={`${raleway.className} mx-auto w-224 text-xl`}>
|
|
||||||
{domToReact(node.children)}
|
|
||||||
</h1>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {match: false};
|
|
||||||
}
|
|
||||||
|
|
||||||
function code(node: Element): nodeHandlerResult {
|
|
||||||
if (node.name === "code") {
|
|
||||||
let linenumber = false;
|
|
||||||
if ("class" in node.attribs) {
|
|
||||||
const classes = node.attribs.class.split(" ");
|
|
||||||
if (classes.includes("linenumber")) {
|
|
||||||
linenumber = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
match: true,
|
|
||||||
element: (
|
|
||||||
<div className={`w-224 mx-auto`}>
|
|
||||||
<Code
|
|
||||||
lang={`${node.attribs.lang}`}
|
|
||||||
lineNumbers={linenumber}
|
|
||||||
extensions={[mark]}
|
|
||||||
>
|
|
||||||
{domToReact(node.children)}
|
|
||||||
</Code>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {match: false};
|
|
||||||
}
|
|
||||||
|
|
||||||
function p(node: Element): nodeHandlerResult {
|
|
||||||
if (node.name === "p") {
|
|
||||||
if (node.attribs.class === "paragraph") {
|
|
||||||
return {
|
|
||||||
match: true,
|
|
||||||
element: (
|
|
||||||
<h1 className={`${nunito_sans.className} mx-auto w-224`}>
|
|
||||||
{domToReact(node.children)}
|
|
||||||
</h1>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {match: false};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handlers: nodeHandler[] = [h1, h2, h3, code, p];
|
|
||||||
@@ -1,45 +1,25 @@
|
|||||||
import { getPost } from "@/backend/post";
|
import { getPost } from '@/backend/post';
|
||||||
import DOMPurify from "dompurify";
|
import { content } from '@/components/post'
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import parse, {
|
|
||||||
Element,
|
|
||||||
// domToReact,
|
|
||||||
HTMLReactParserOptions,
|
|
||||||
} from "html-react-parser";
|
|
||||||
import { DummyPostSlug, DummyPostString } from "@/components/dummyPost";
|
|
||||||
import { handlers } from "@/app/post/[slug]/nodeHandler";
|
|
||||||
|
|
||||||
const options: HTMLReactParserOptions = {
|
|
||||||
replace: (domNode) => {
|
|
||||||
for (let handler of handlers) {
|
|
||||||
if (domNode instanceof Element && domNode.attribs) {
|
|
||||||
let result = handler(domNode)
|
|
||||||
if (result.match && typeof result.element !== 'undefined') {
|
|
||||||
return result.element
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function Post({ params }: { params: { slug: string } }) {
|
export default async function Post({ params }: { params: { slug: string } }) {
|
||||||
let content;
|
// let content;
|
||||||
|
//
|
||||||
|
// const dummySlug = DummyPostSlug();
|
||||||
|
// if (dummySlug === params.slug) {
|
||||||
|
// content = await DummyPostString();
|
||||||
|
// } else {
|
||||||
|
// content = await getPost(params.slug);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// content = DOMPurify(new JSDOM("<!DOCTYPE html>").window).sanitize(content);
|
||||||
|
// const elem = parse(content, options);
|
||||||
|
//
|
||||||
|
// return (
|
||||||
|
// <div className={`flex flex-col`}>
|
||||||
|
// {elem}
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
|
||||||
const dummySlug = await DummyPostSlug();
|
let postContent :string = await getPost(params.slug);
|
||||||
if (dummySlug === params.slug) {
|
return(content(postContent));
|
||||||
content = await DummyPostString();
|
|
||||||
// console.log(content);
|
|
||||||
} else {
|
|
||||||
content = await getPost(params.slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
content = DOMPurify(new JSDOM("<!DOCTYPE html>").window).sanitize(content);
|
|
||||||
// console.log(content)
|
|
||||||
const elem = parse(content, options);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col`}>
|
|
||||||
{elem}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import mysql, { PoolOptions, Pool } from "mysql2";
|
import mysql, { PoolOptions, Pool } from "mysql2";
|
||||||
import { Pool as pPool } from "mysql2/promise"
|
import { Pool as pPool } from "mysql2/promise"
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as appEnv from "./env";
|
import * as appEnv from "@/backend/env";
|
||||||
|
|
||||||
let pool: Pool | undefined;
|
let pool: Pool | undefined;
|
||||||
let promisePool: pPool | undefined;
|
let promisePool: pPool | undefined;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
export function getAssetsDomain(): string {
|
||||||
|
if (typeof process.env.ASSETS_DOMAIN === 'undefined') {
|
||||||
|
throw new Error("missing env ASSETS_DOMAIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.ASSETS_DOMAIN;
|
||||||
|
}
|
||||||
|
|
||||||
export function getMysqlHost(): string {
|
export function getMysqlHost(): string {
|
||||||
if (typeof process.env.MYSQL_HOST === 'undefined') {
|
if (typeof process.env.MYSQL_HOST === 'undefined') {
|
||||||
throw new Error("missing env MYSQL_HOST")
|
throw new Error("missing env MYSQL_HOST")
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Extension } from 'bright';
|
|
||||||
|
|
||||||
export const lineNumbers :Extension = {
|
|
||||||
name: "lineNumbers",
|
|
||||||
beforeHighlight: (props, annotations) => {
|
|
||||||
console.log(annotations);
|
|
||||||
if (annotations.length > 0 ) {
|
|
||||||
return { ...props, lineNumbers: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Extension } from "bright";
|
|
||||||
|
|
||||||
export const mark: Extension = {
|
|
||||||
name: "mark",
|
|
||||||
InlineAnnotation: ({ children, query }) => {
|
|
||||||
return (
|
|
||||||
<mark style={{ background: query }}>{children}</mark>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
MultilineAnnotation: ({ children, query }) => {
|
|
||||||
return (
|
|
||||||
<div style={{ background: query }}>{children}</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { promises as fsp } from 'fs'
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
export async function DummyPostString() {
|
export async function MarkPostString() {
|
||||||
let path = ""
|
let path = ""
|
||||||
if ('DUMMY_HTML_DIR' in process.env && typeof process.env.DUMMY_HTML_DIR === "string") {
|
if ('DUMMY_HTML_DIR' in process.env && typeof process.env.DUMMY_HTML_DIR === "string") {
|
||||||
path = process.env.DUMMY_HTML_DIR + "test1.html";
|
path = process.env.DUMMY_HTML_DIR + "test1.md";
|
||||||
}
|
}
|
||||||
return await fsp.readFile(path, "utf-8")
|
return await readFile(path, "utf-8")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DummyPostSlug() {
|
export function DummyPostSlug() {
|
||||||
return "dummy-post"
|
return "dummy-post"
|
||||||
}
|
}
|
||||||
47
components/homePostList.tsx
Normal file
47
components/homePostList.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import {getAssetsDomain} from "@/backend/env";
|
||||||
|
import {questrial, syne} from "@/app/fonts";
|
||||||
|
import { RowDataPacket } from "mysql2";
|
||||||
|
import { getPromisePool } from "@/backend/db";
|
||||||
|
|
||||||
|
interface post extends RowDataPacket {
|
||||||
|
id: number,
|
||||||
|
slug: string,
|
||||||
|
title: string,
|
||||||
|
preview: string,
|
||||||
|
cover_url: string,
|
||||||
|
cover_alt: string,
|
||||||
|
preview_cover_height: number,
|
||||||
|
preview_cover_width: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HomePostListProps = {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function HomePostList(props :HomePostListProps) {
|
||||||
|
const [posts] = await getPromisePool().query<post[]>(
|
||||||
|
'SELECT * FROM post LIMIT 10;'
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={props.className}>
|
||||||
|
{posts.map(post => (
|
||||||
|
<div key={post.id} className={`border border-slate-100 flex flex-col`}>
|
||||||
|
<Link
|
||||||
|
href={`/post/${post.slug}`}
|
||||||
|
className={`flex flex-row max-w-4xl items-center`} >
|
||||||
|
<Image src={post.cover_url}
|
||||||
|
alt={post.cover_alt}
|
||||||
|
height={post.preview_cover_height}
|
||||||
|
width={post.preview_cover_width} />
|
||||||
|
<div className={`flex flex-col mx-10`}>
|
||||||
|
<p className={`${syne.className} text-2xl`}>{post.title}</p>
|
||||||
|
<p className={`${questrial.className} line-clamp-3 mt-4`}>{post.preview}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
components/post.tsx
Normal file
120
components/post.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { unified } from 'unified';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remarkRehype, { Options as RemarkRehypeOptions } from 'remark-rehype';
|
||||||
|
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||||
|
import rehypeHighlight, { Options as RehypeHighlightOptions } from 'rehype-highlight';
|
||||||
|
import rehypeReact, { Options as RehypeReactOptions } from 'rehype-react';
|
||||||
|
import { all } from 'lowlight';
|
||||||
|
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
|
||||||
|
import { raleway, roboto, nunito } from "@/app/fonts";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { getAssetsDomain } from "@/backend/env";
|
||||||
|
|
||||||
|
export async function content(content: string) {
|
||||||
|
let result = await unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkRehype, { allowDangerousHtml: true } as RemarkRehypeOptions)
|
||||||
|
.use(rehypeRaw)
|
||||||
|
.use(rehypeSanitize, {
|
||||||
|
...defaultSchema,
|
||||||
|
tagNames: [
|
||||||
|
...(defaultSchema.tagNames || []),
|
||||||
|
'figure',
|
||||||
|
'figcaption',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.use(rehypeHighlight, {
|
||||||
|
languages: all,
|
||||||
|
} as RehypeHighlightOptions)
|
||||||
|
.use(rehypeReact, {
|
||||||
|
Fragment: Fragment,
|
||||||
|
jsx: jsx,
|
||||||
|
jsxs: jsxs,
|
||||||
|
passNode: true,
|
||||||
|
components: {
|
||||||
|
h1: (props) => (
|
||||||
|
<h1 className={`${raleway.className} mx-auto w-224 text-4xl`}>
|
||||||
|
{props.children}
|
||||||
|
</h1>
|
||||||
|
),
|
||||||
|
h2: (props) => (
|
||||||
|
<h2 className={`${raleway.className} mx-auto mt-6 w-224 text-3xl`}>
|
||||||
|
{props.children}
|
||||||
|
</h2>
|
||||||
|
),
|
||||||
|
h3: (props) => (
|
||||||
|
<h3 className={`${raleway.className} mx-auto mt-4 w-224 text-2xl`}>
|
||||||
|
{props.children}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
h4: (props) => (
|
||||||
|
<h4 className={`${raleway.className} mx-auto mt-3 w-224 text-xl`}>
|
||||||
|
{props.children}
|
||||||
|
</h4>
|
||||||
|
),
|
||||||
|
p: (props) => (
|
||||||
|
<p className={`${nunito.className} mx-auto mt-2 w-224`}>
|
||||||
|
{props.children}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
pre: (props) => (
|
||||||
|
<div className={`w-224 mx-auto mt-2`}>
|
||||||
|
<pre>{props.children}</pre>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
hr: (props) => <hr className={`mx-auto w-224 mt-6`} />,
|
||||||
|
img: (props) => {
|
||||||
|
let src: string = "";
|
||||||
|
let alt: string = "";
|
||||||
|
|
||||||
|
if (typeof props.src === "undefined") {
|
||||||
|
src = `https://${getAssetsDomain()}/broken-image.svg`;
|
||||||
|
} else {
|
||||||
|
src = props.src;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.alt === "string") {
|
||||||
|
alt = props.alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof props.width === "undefined" ||
|
||||||
|
typeof props.height === "undefined"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Image src={src} alt={alt} className={`mx-auto`} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={`mx-auto`}
|
||||||
|
width={
|
||||||
|
typeof props.width === "string"
|
||||||
|
? parseInt(props.width, 10)
|
||||||
|
: props.width
|
||||||
|
}
|
||||||
|
height={
|
||||||
|
typeof props.height === "string"
|
||||||
|
? parseInt(props.height, 10)
|
||||||
|
: props.height
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
figcaption: (props) => (
|
||||||
|
<figcaption className={`${nunito.className} text-center mb-6`}>
|
||||||
|
{props.children}
|
||||||
|
</figcaption>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
} as RehypeReactOptions)
|
||||||
|
.process(content);
|
||||||
|
|
||||||
|
return result.result;
|
||||||
|
}
|
||||||
337
dummies/test1.md
Normal file
337
dummies/test1.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# Nginx + SSL Client Certificate Verification: Manage access to a site
|
||||||
|
|
||||||
|
Access control is a fundamental part of security. Most entities rely on
|
||||||
|
the combination of username and password, sometimes with additional multi-factor authentication
|
||||||
|
to improve security. Some entities also use the SSL client certificate verification to manage access
|
||||||
|
to specific resources. One of the use cases where SSL client certificate verification fits perfectly is
|
||||||
|
managing access to internet-facing development or staging servers. In this post, I'll share how
|
||||||
|
to set up the certificates and configure nginx to verify users based on their certificates.
|
||||||
|
|
||||||
|
## Preparing the certificates
|
||||||
|
|
||||||
|
There are two certificates we are going to create. The first one is the root
|
||||||
|
certificate. It will be placed in the Nginx server. The second one is the client certificate. It will
|
||||||
|
be installed in the client machine/browsers.
|
||||||
|
|
||||||
|
### Root CA
|
||||||
|
|
||||||
|
For generating a root CA, execute these two steps:
|
||||||
|
|
||||||
|
#### Generate RSA Key
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl genrsa -aes256 -out ca.key 4096
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create Root CA file
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup CA configuration
|
||||||
|
|
||||||
|
This is an optional step, but if you want to be able to revoke access you previously granted, you need to do this step.
|
||||||
|
Create a file named ca.cnf in the same directory as the ca.key and ca.crt.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[ ca ]
|
||||||
|
default_ca = gca
|
||||||
|
|
||||||
|
[ crl_ext ]
|
||||||
|
authorityKeyIdentifier=keyid:always
|
||||||
|
|
||||||
|
[ gca ]
|
||||||
|
dir = ./
|
||||||
|
new_certs_dir = $dir
|
||||||
|
unique_subject = no
|
||||||
|
certificate = $dir/ca.crt
|
||||||
|
database = $dir/certindex
|
||||||
|
private_key = $dir/ca.key
|
||||||
|
serial = $dir/certserial
|
||||||
|
default_days = 365
|
||||||
|
default_md = sha256
|
||||||
|
policy = gca_policy
|
||||||
|
x509_extensions = gca_extensions
|
||||||
|
crlnumber = $dir/crlnumber
|
||||||
|
default_crl_days = 365
|
||||||
|
|
||||||
|
[ gca_policy ]
|
||||||
|
commonName = supplied
|
||||||
|
stateOrProvinceName = supplied
|
||||||
|
countryName = optional
|
||||||
|
emailAddress = optional
|
||||||
|
organizationName = supplied
|
||||||
|
organizationUnitName = optional
|
||||||
|
|
||||||
|
[ gca_extensions ]
|
||||||
|
basicConstraints = CA:false
|
||||||
|
subjectKeyIdentifier = hash
|
||||||
|
authorityKeyIdentifier = keyid:always
|
||||||
|
keyUsage = digitalSignature,keyEncipherment
|
||||||
|
extendedKeyUsage = serverAuth
|
||||||
|
crlDistributionPoints = URI:http://example.com/root.crl
|
||||||
|
subjectAltName = @alt_names
|
||||||
|
|
||||||
|
[ alt_names ]
|
||||||
|
DNS.1 = example.com
|
||||||
|
DNS.2 = *.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize an empty file for the CA database.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
touch certindex
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialize value for certserial and crlnumber
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo 01 > certserial
|
||||||
|
echo 01 > crlnumber
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Certificates
|
||||||
|
|
||||||
|
#### Generate the user RSA key
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl genrsa -aes256 -out client01/user.key 4096
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create Certificate-Signing Request (CSR)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl req -new -key client01/user.key -out client01/user.csr
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sign the CSR
|
||||||
|
|
||||||
|
If you did the setup CA configuration step, sign the CSR file by running this command.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl ca -config ca.cnf -in client01/user.csr -out client01/user.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
If you skipped the setup CA configuration step, sign the CSR file by running this command.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl x509 -req -days 365 -in client01/user.csr -CA ca.crt -CAkey ca.key -set_serial 01 -out client01/user.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Convert the crt file to pfx/p12 file
|
||||||
|
|
||||||
|
Most of the time, browsers/client machines only accept a certificate in the pfx format. Run this
|
||||||
|
command to convert the crt file to the pfx/p12 format
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl pkcs12 -export -out client01/user.pfx -inkey client01/user.key -in client01/user.crt -certfile ca.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll be prompted to enter an export password. You must input the exact password when adding
|
||||||
|
the certificate to a browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setting up nginx with client certificates verification
|
||||||
|
|
||||||
|
Add these lines to a server block in your nginx configuration
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
ssl_client_certificate /path/to/client/verfication/ca.crt;
|
||||||
|
ssl_verify_client optional;
|
||||||
|
ssl_verify_depth 2;
|
||||||
|
```
|
||||||
|
|
||||||
|
You can do location-based access control. Location-based here refers to a location block in your
|
||||||
|
nginx configuration, for example:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /private {
|
||||||
|
if ($ssl_client_verify != SUCCESS) { # add this condition
|
||||||
|
return 403; # to make nginx return 403
|
||||||
|
} # when the client has no valid certificate
|
||||||
|
|
||||||
|
....
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Here is a complete example of a server block in the nginx configuration
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
|
||||||
|
server_name www.example.com;
|
||||||
|
ssl_certificate /path/to/your/https/certificate.pem;
|
||||||
|
ssl_certificate_key /path/to/your/https/private-key.pem;
|
||||||
|
include snippets/ssl-params.conf;
|
||||||
|
|
||||||
|
# the folowing three lines make nginx verify client certificate
|
||||||
|
ssl_client_certificate /path/to/client/verification/ca.crt;
|
||||||
|
ssl_verify_client optional;
|
||||||
|
ssl_verify_depth 2;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
if ($ssl_client_verify != SUCCESS) { # add this condition
|
||||||
|
return 403; # to make nginx return 403
|
||||||
|
} # when the client has no valid certificate
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding the User Certificates to the client machine/browsers
|
||||||
|
|
||||||
|
Most browsers have a search feature on their setting page. Just search for certificates. It will show you the setting
|
||||||
|
for certificate management.
|
||||||
|
|
||||||
|
### Google Chrome (and Chromium-based browsers)
|
||||||
|
|
||||||
|
Google Chrome and most Chromium-based (e.g., Vivaldi, Microsoft Edge) browsers installed on Windows or Mac rely on
|
||||||
|
the operating system key management. So when you open the setting for certificate management, an external window
|
||||||
|
will open.
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<img alt="MacOS Keychain Access" src="https://assets.suyono.me/post/nginx-ssl-client-certificate-verification-manage-access-to-a-site/mac_keychain_1.png" width="740" height="483">
|
||||||
|
<figcaption>MacOS Keychain Access Window, accessible from settings page</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
On Mac, it is called Keychain Access. To add a certificate, drag the pfx file onto Keychain Access. You'll need to
|
||||||
|
input the exact export password when you convert the crt file to pfx/p12 format.
|
||||||
|
|
||||||
|
When you click Manage device certificate from the browser setting page, this window will open on Windows. You can import
|
||||||
|
the pfx file using this window.
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<img alt="Windows certificate dialog" src="https://assets.suyono.me/post/nginx-ssl-client-certificate-verification-manage-access-to-a-site/certificates.png" width="503" height="467">
|
||||||
|
<figcaption>Certificates dialog on Widows, open from chrome settings page</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
Alternatively, you can use the certmgr to import the certificate. You can open it from the Windows setting or Control Panel.
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<img alt="Windows certificates manager" src="https://assets.suyono.me/post/nginx-ssl-client-certificate-verification-manage-access-to-a-site/certmgr.png" width="626" height="444">
|
||||||
|
<figcaption>Windows certificates manager, accessible from Control Panel</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
### Mozilla Firefox
|
||||||
|
|
||||||
|
Firefox has its own Certificate Manager dialog. You can import and manage the certificate from it. It also connects to
|
||||||
|
the operating system certificate management.
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<img alt="Firefox certificates manager" src="https://assets.suyono.me/post/nginx-ssl-client-certificate-verification-manage-access-to-a-site/firefox_certificate_manager_1.png" width="740" height="441">
|
||||||
|
<figcaption>Mozilla Firefox Certificate Manager</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
You can use any browser or tool, like cURL, to test the client certificate verification setup. If your client
|
||||||
|
certificate verification succeeds, you can open the page using a browser. If your browser shows something like
|
||||||
|
403 Forbidden, it means either your browser does not have the certificate or something wrong in your setup.
|
||||||
|
|
||||||
|
### cURL
|
||||||
|
|
||||||
|
Without a valid client certificate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -v https://www.example.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
response
|
||||||
|
|
||||||
|
```
|
||||||
|
> GET / HTTP/2
|
||||||
|
> Host: www.example.com
|
||||||
|
> user-agent: curl/7.74.0
|
||||||
|
> accept: */*
|
||||||
|
>
|
||||||
|
< HTTP/2 403
|
||||||
|
< server: nginx
|
||||||
|
< date: Wed, 12 Jul 2023 04:54:02 GMT
|
||||||
|
< content-type: text/html
|
||||||
|
< content-length: 146
|
||||||
|
<
|
||||||
|
<html>
|
||||||
|
<head><title>403 Forbidden</title></head>
|
||||||
|
<body>
|
||||||
|
<center><h1>403 Forbidden</h1></center>
|
||||||
|
<hr><center>nginx</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
With a valid client certificate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --cert user.crt --key user.key -v https://www.example.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
response
|
||||||
|
|
||||||
|
```
|
||||||
|
> GET / HTTP/2
|
||||||
|
> Host: www.example.com
|
||||||
|
> user-agent: curl/7.74.0
|
||||||
|
> accept: */*
|
||||||
|
>
|
||||||
|
< HTTP/2 200
|
||||||
|
< server: nginx
|
||||||
|
.
|
||||||
|
.
|
||||||
|
.
|
||||||
|
snipped
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revoking Access
|
||||||
|
|
||||||
|
This setup recognizes users by the certificate they are using. Revoking access here means revoking the users'
|
||||||
|
certificates. We can achieve this by leveraging OpenSSL's CRL feature. To use it, we need to have the CA database.
|
||||||
|
I explained how to set it up in the section above.
|
||||||
|
|
||||||
|
### Revoke client certificate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl ca -config ca.cnf -revoke user.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate CRL file
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl ca -config ca.cnf -gencrl -out crl.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying CRL file
|
||||||
|
|
||||||
|
```sh
|
||||||
|
openssl crl -in crl.pem -noout -text
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx configuration for CRL
|
||||||
|
|
||||||
|
You need to add the `ssl_crl` directive in the Nginx configuration file, as shown in the example below.
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
....
|
||||||
|
ssl_client_certificate /path/to/client/verification/ca.crt;
|
||||||
|
ssl_verify_client optional;
|
||||||
|
ssl_verify_depth 2;
|
||||||
|
ssl_crl /path/to/crl.pem; # configure nginx to read the crl file
|
||||||
|
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
....
|
||||||
|
```
|
||||||
|
|
||||||
33
package.json
33
package.json
@@ -9,29 +9,36 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/dompurify": "^3.0.5",
|
|
||||||
"@types/jsdom": "^21.1.6",
|
|
||||||
"@types/node": "20.6.5",
|
"@types/node": "20.6.5",
|
||||||
"@types/react": "18.2.22",
|
"@types/react": "18.3.1",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.3.0",
|
||||||
"autoprefixer": "10.4.16",
|
"autoprefixer": "10.4.19",
|
||||||
"bright": "^0.8.5",
|
|
||||||
"dompurify": "^3.1.2",
|
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-next": "14.2.3",
|
"eslint-config-next": "14.2.3",
|
||||||
"html-react-parser": "^4.2.10",
|
"highlight.js": "^11.9.0",
|
||||||
"jsdom": "^22.1.0",
|
"lowlight": "^3.1.0",
|
||||||
"mysql2": "^3.9.7",
|
"mysql2": "^3.9.7",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
"postcss": "8.4.30",
|
"postcss": "8.4.38",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
"react-remark": "^2.1.0",
|
||||||
"redis": "^4.6.13",
|
"redis": "^4.6.13",
|
||||||
|
"rehype-highlight": "^7.0.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-react": "^8.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"rehype-stringify": "^10.0.0",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.0",
|
||||||
"sharp": "^0.33.3",
|
"sharp": "^0.33.3",
|
||||||
"tailwindcss": "3.3.3",
|
"tailwindcss": "3.4.3",
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2",
|
||||||
|
"unified": "^11.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "3.0.3"
|
"prettier": "3.0.3"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab"
|
||||||
}
|
}
|
||||||
|
|||||||
1796
pnpm-lock.yaml
generated
1796
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user