feat: secure KYC storage, Google OAuth, terms gating
This commit is contained in:
+27
-1
@@ -1,10 +1,22 @@
|
||||
import { AuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Adapter dipakai untuk persist User + Account saat OAuth (Google).
|
||||
// Session tetap pakai JWT supaya kompatibel dengan CredentialsProvider.
|
||||
export const authOptions: AuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
// Auto-link kalau email Google sama dengan email user yang sudah register
|
||||
// via Credentials. Aman karena Google selalu memverifikasi email pemilik akun.
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
}),
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
@@ -24,6 +36,10 @@ export const authOptions: AuthOptions = {
|
||||
throw new Error("Email tidak ditemukan");
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error("Akun ini terdaftar via Google. Silakan login dengan Google.");
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
credentials.password,
|
||||
user.password
|
||||
@@ -46,15 +62,25 @@ export const authOptions: AuthOptions = {
|
||||
strategy: "jwt",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
async jwt({ token, user, trigger }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
// Hidrasi `acceptedTermsAndPrivacy` dari DB pada login pertama dan setiap
|
||||
// kali client memanggil `useSession().update()` (setelah user accept).
|
||||
if (token.id && (trigger === "update" || token.acceptedTermsAndPrivacy === undefined)) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: token.id as string },
|
||||
select: { acceptedTermsAndPrivacy: true },
|
||||
});
|
||||
token.acceptedTermsAndPrivacy = dbUser?.acceptedTermsAndPrivacy ?? false;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.acceptedTermsAndPrivacy = token.acceptedTermsAndPrivacy ?? false;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
function readKey(envName: string): Buffer {
|
||||
const hex = process.env[envName];
|
||||
if (!hex) throw new Error(`Missing env ${envName}`);
|
||||
if (hex.length !== 64) {
|
||||
throw new Error(`${envName} must be 64 hex chars (32 bytes)`);
|
||||
}
|
||||
return Buffer.from(hex, "hex");
|
||||
}
|
||||
|
||||
function getEncKey(): Buffer {
|
||||
return readKey("KYC_ENCRYPTION_KEY");
|
||||
}
|
||||
|
||||
function getNikPepper(): Buffer {
|
||||
return readKey("KYC_NIK_PEPPER");
|
||||
}
|
||||
|
||||
/** Encrypt a Buffer with AES-256-GCM. Output layout: [iv(12) | tag(16) | ciphertext]. */
|
||||
export function encryptBuffer(plain: Buffer): Buffer {
|
||||
const iv = crypto.randomBytes(IV_LEN);
|
||||
const cipher = crypto.createCipheriv(ALGO, getEncKey(), iv);
|
||||
const ct = Buffer.concat([cipher.update(plain), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, ct]);
|
||||
}
|
||||
|
||||
export function decryptBuffer(blob: Buffer): Buffer {
|
||||
if (blob.length < IV_LEN + TAG_LEN) throw new Error("Ciphertext too short");
|
||||
const iv = blob.subarray(0, IV_LEN);
|
||||
const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ct = blob.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = crypto.createDecipheriv(ALGO, getEncKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
}
|
||||
|
||||
/** Encrypt UTF-8 string -> base64 string. Used for short PII like NIK. */
|
||||
export function encryptString(plain: string): string {
|
||||
return encryptBuffer(Buffer.from(plain, "utf8")).toString("base64");
|
||||
}
|
||||
|
||||
export function decryptString(b64: string): string {
|
||||
return decryptBuffer(Buffer.from(b64, "base64")).toString("utf8");
|
||||
}
|
||||
|
||||
/** Deterministic HMAC-SHA256 of a normalized value, hex-encoded. Used for unique-lookup of NIK without storing plaintext. */
|
||||
export function hmacHex(value: string): string {
|
||||
return crypto.createHmac("sha256", getNikPepper()).update(value).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { encryptBuffer, decryptBuffer } from "@/lib/crypto";
|
||||
|
||||
export type KycKind = "ktp" | "selfie";
|
||||
|
||||
const KIND_DIRS: Record<KycKind, string> = {
|
||||
ktp: "ktp",
|
||||
selfie: "selfie",
|
||||
};
|
||||
|
||||
/** Bytes. ~5MB matches the form limit; raise here if you change the upload route. */
|
||||
export const MAX_KYC_FILE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export const ALLOWED_KYC_MIME = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
|
||||
function rootDir(): string {
|
||||
const fromEnv = process.env.KYC_UPLOAD_DIR;
|
||||
if (fromEnv && fromEnv.trim().length > 0) return fromEnv;
|
||||
return path.join(process.cwd(), "uploads", "private");
|
||||
}
|
||||
|
||||
function dirFor(kind: KycKind): string {
|
||||
return path.join(rootDir(), KIND_DIRS[kind]);
|
||||
}
|
||||
|
||||
/** Storage key written into DB: `<kind>/<id>.<ext>`. The kind segment is enforced to match the route. */
|
||||
export type StoredFileMeta = {
|
||||
key: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export function isKycKind(value: string): value is KycKind {
|
||||
return value === "ktp" || value === "selfie";
|
||||
}
|
||||
|
||||
/** Resolve a storage key (`ktp/abc.jpg`) to an absolute path inside the upload dir. Throws on traversal. */
|
||||
function resolveKey(kind: KycKind, key: string): string {
|
||||
const expectedPrefix = `${KIND_DIRS[kind]}/`;
|
||||
if (!key.startsWith(expectedPrefix)) {
|
||||
throw new Error("Storage key does not match kind");
|
||||
}
|
||||
const relative = key.slice(expectedPrefix.length);
|
||||
if (!/^[A-Za-z0-9_-]+\.(jpg|png|webp)$/.test(relative)) {
|
||||
throw new Error("Storage key has invalid characters");
|
||||
}
|
||||
const abs = path.join(dirFor(kind), relative);
|
||||
const dir = dirFor(kind);
|
||||
if (!abs.startsWith(dir + path.sep) && abs !== dir) {
|
||||
throw new Error("Storage key escapes upload directory");
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
export async function saveEncrypted(
|
||||
kind: KycKind,
|
||||
data: Buffer,
|
||||
mime: string,
|
||||
): Promise<StoredFileMeta> {
|
||||
if (!ALLOWED_KYC_MIME.has(mime)) throw new Error("Tipe file tidak didukung");
|
||||
if (data.length === 0) throw new Error("File kosong");
|
||||
if (data.length > MAX_KYC_FILE_BYTES) throw new Error("File terlalu besar");
|
||||
|
||||
const ext = EXT_BY_MIME[mime];
|
||||
const id = crypto.randomBytes(16).toString("hex");
|
||||
const key = `${KIND_DIRS[kind]}/${id}.${ext}`;
|
||||
const abs = resolveKey(kind, key);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
const blob = encryptBuffer(data);
|
||||
await fs.writeFile(abs, blob, { mode: 0o600 });
|
||||
return { key, mime, size: data.length };
|
||||
}
|
||||
|
||||
export async function readDecrypted(kind: KycKind, key: string): Promise<Buffer> {
|
||||
const abs = resolveKey(kind, key);
|
||||
const blob = await fs.readFile(abs);
|
||||
return decryptBuffer(blob);
|
||||
}
|
||||
|
||||
export async function deleteFile(kind: KycKind, key: string): Promise<void> {
|
||||
const abs = resolveKey(kind, key);
|
||||
await fs.rm(abs, { force: true });
|
||||
}
|
||||
|
||||
export function mimeFromKey(key: string): string {
|
||||
if (key.endsWith(".jpg")) return "image/jpeg";
|
||||
if (key.endsWith(".png")) return "image/png";
|
||||
if (key.endsWith(".webp")) return "image/webp";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
Reference in New Issue
Block a user