Skip to content

Commit

Permalink
feat: use non-node for login (#366)
Browse files Browse the repository at this point in the history
  • Loading branch information
ImJustChew committed Jun 12, 2024
2 parents 33eb74f + cb452d4 commit aa9c88b
Show file tree
Hide file tree
Showing 12 changed files with 226 additions and 70 deletions.
2 changes: 1 addition & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({

/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config, { webpack }) => {
webpack: (config, { webpack, isServer }) => {
config.plugins.push(
new webpack.DefinePlugin({
__SENTRY_DEBUG__: false,
Expand Down
123 changes: 110 additions & 13 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@
"iconv-lite": "^0.6.3",
"ics": "^3.5.0",
"idb-keyval": "^6.2.1",
"jose": "^5.4.0",
"jsdom": "^24.1.0",
"jsonwebtoken": "^9.0.2",
"leaflet": "^1.9.4",
"linkedom": "^0.18.3",
"lucide-react": "^0.383.0",
"negotiator": "^0.6.3",
"next": "^14.2.3",
Expand Down
9 changes: 7 additions & 2 deletions src/app/api/ais_auth/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { refreshUserSession, signInToCCXP } from "@/lib/headless_ais";
import { NextRequest, NextResponse } from "next/server";

export const runtime = 'edge';

export const POST = async (req: NextRequest) => {
const form = await req.formData();
const studentid = form.get("studentid");
const encryptedPassword = form.get("encryptedPassword");

if(!studentid || !encryptedPassword) return NextResponse.json({ error: { message: "Missing Student ID and Password" }}, { status: 400 })

return NextResponse.json(await refreshUserSession(studentid as string, encryptedPassword as string));
const res = await refreshUserSession(studentid as string, encryptedPassword as string);
if(!res) {
return NextResponse.json({ error: { message: "Something went wrong" }}, { status: 500 });
}
return NextResponse.json(res);
}
9 changes: 7 additions & 2 deletions src/app/api/ais_auth/signin/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { signInToCCXP } from "@/lib/headless_ais";
import { NextRequest, NextResponse } from "next/server";

export const runtime = 'edge';

export const POST = async (req: NextRequest) => {
const form = await req.formData();
const studentid = form.get("studentid");
const password = form.get("password");

if(!studentid || !password) return NextResponse.json({ error: { message: "Missing Student ID and Password" }}, { status: 400 })

return NextResponse.json(await signInToCCXP(studentid as string, password as string));
const res = await signInToCCXP(studentid as string, password as string)
if(!res) {
return NextResponse.json({ error: { message: "Something went wrong" }}, { status: 500 });
}
return NextResponse.json(res);
}
2 changes: 1 addition & 1 deletion src/app/api/scrape-archived-courses/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const GET = async (request: NextRequest, _try = 0) => {

const text = await fetchCourses(department, semester)
.then(res => res.arrayBuffer())
.then(arrayBuffer => iconv.decode(Buffer.from(arrayBuffer), 'big5').toString());
.then(arrayBuffer => new TextDecoder('big5').decode(new Uint8Array(arrayBuffer)))

const dom = new jsdom.JSDOM(text);
const doc = dom.window.document;
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/scrape-syllabus/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const GET = async (request: NextRequest) => {
const fetchSyllabusHTML = async (c_key: string) => {
const text = await fetch(baseURL + encodeURIComponent(c_key))
.then(res => res.arrayBuffer())
.then(arrayBuffer => iconv.decode(Buffer.from(arrayBuffer), 'big5').toString())
.then(arrayBuffer => new TextDecoder('big5').decode(new Uint8Array(arrayBuffer)))
return text;
}
const courses = await fetchCourses();
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/contexts/useHeadlessAIS.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { FC, PropsWithChildren, createContext, useContext, useEffect, useState }
import { useLocalStorage } from 'usehooks-ts';
import useDictionary from "@/dictionaries/useDictionary";
import { useCookies } from "react-cookie";
import { decode } from 'jsonwebtoken';
import {fetchRefreshUserSession, fetchSignInToCCXP} from '@/helpers/headless_ais';
import { decodeJwt } from 'jose';
const headlessAISContext = createContext<ReturnType<typeof useHeadlessAISProvider>>({
user: undefined,
ais: {
Expand Down Expand Up @@ -38,7 +38,7 @@ const useHeadlessAISProvider = () => {
getACIXSTORE(true)
}
else if(cookies.accessToken){
const { exp } = decode(cookies.accessToken ?? '') as { exp: number };
const { exp } = decodeJwt(cookies.accessToken ?? '') as { exp: number };
if (Date.now() >= exp * 1000) {
getACIXSTORE(true)
}
Expand Down Expand Up @@ -162,7 +162,7 @@ const useHeadlessAISProvider = () => {
}

return {
user: cookies.accessToken ? decode(cookies.accessToken, { json: true }) as UserJWT | null : undefined ,
user: cookies.accessToken ? decodeJwt(cookies.accessToken) as UserJWT | null : undefined ,
ais,
loading,
error,
Expand Down
107 changes: 78 additions & 29 deletions src/lib/headless_ais.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,75 @@
'use server';
import { LoginError, UserJWT, UserJWTDetails } from "@/types/headless_ais";
import jwt from 'jsonwebtoken';
import { cookies } from "next/headers";
import jsdom from 'jsdom';
import iconv from 'iconv-lite';
import { parseHTML } from 'linkedom';
import supabase_server from "@/config/supabase_server";
import crypto from 'crypto';
import * as jose from 'jose'

export const encrypt = async (text: string) => {
const iv = crypto.randomBytes(16);
const key = Buffer.from(process.env.NTHU_HEADLESS_AIS_ENCRYPTION_KEY!, 'hex');
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = cipher.update(text, 'utf8', 'base64') + cipher.final('base64');
const encryptedPassword = iv.toString('base64') + encrypted;
return encryptedPassword;
function hexStringToUint8Array(hexString: string) {
if (hexString.length % 2 !== 0) {
throw new Error("Hex string has an odd length");
}
const arrayBuffer = new Uint8Array(hexString.length / 2);
for (let i = 0; i < hexString.length; i += 2) {
const byteValue = parseInt(hexString.substring(i, i + 2), 16);
arrayBuffer[i / 2] = byteValue;
}
return arrayBuffer;
}

export const decrypt = async (encryptedPassword: string) => {
const key = Buffer.from(process.env.NTHU_HEADLESS_AIS_ENCRYPTION_KEY!, 'hex');
export const encrypt = async (text: string) => {
const key = await crypto.subtle.importKey(
'raw',
hexStringToUint8Array(process.env.NTHU_HEADLESS_AIS_ENCRYPTION_KEY!),
{ name: 'AES-CBC', length: 256 }, // Specify algorithm details
false,
['encrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv },
key,
new TextEncoder().encode(text)
);

// Split the IV and the encrypted text
const iv = Buffer.from(encryptedPassword.slice(0, 24), 'base64'); // First 24 characters represent the IV
const encryptedText = encryptedPassword.slice(24); // The rest is the encrypted text
// Correctly handle binary data and Base64 encoding
const encryptedData = new Uint8Array(encrypted); // Convert BufferSource to Uint8Array
const ivBase64 = btoa(String.fromCharCode(...iv)); // Encode IV as Base64
const encryptedDataBase64 = btoa(String.fromCharCode(...encryptedData)); // Encode encrypted data as Base64

const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encryptedText, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return await decrypted;
const encryptedPassword = ivBase64 + encryptedDataBase64; // Concatenate Base64 IV and encrypted data
return encryptedPassword;
}

export const decrypt = async (encryptedPassword: string) => {
const encodedKey = hexStringToUint8Array(process.env.NTHU_HEADLESS_AIS_ENCRYPTION_KEY!);
const key = await crypto.subtle.importKey(
'raw',
encodedKey,
{ name: 'AES-CBC', length: 256 }, // Specify algorithm details for consistency
false,
['decrypt'] // Specify that the key is for decryption
);

// Extract the IV from the first part of the Base64 string
const ivBase64 = encryptedPassword.slice(0, 24); // First 24 characters are the Base64 encoded IV
const iv = new Uint8Array(atob(ivBase64).split('').map(char => char.charCodeAt(0)));

// Extract the encrypted data
const encryptedData = encryptedPassword.slice(24);
const encryptedArrayBuffer = new Uint8Array(atob(encryptedData).split('').map(char => char.charCodeAt(0))).buffer;

// Decrypt the data
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv },
key,
encryptedArrayBuffer
);

// Convert the decrypted buffer back to text
const decryptedText = new TextDecoder().decode(decryptedBuffer);
return decryptedText;
}

type SignInToCCXPResponse = Promise<{ ACIXSTORE: string, encryptedPassword: string } | { error: { message: string } }>;
/**
Expand Down Expand Up @@ -167,11 +208,8 @@ export const signInToCCXP = async (studentid: string, password: string): SignInT
"credentials": "include"
})
.then(res => res.arrayBuffer())
.then(arrayBuffer => iconv.decode(Buffer.from(arrayBuffer), 'big5').toString())
const dom = new jsdom.JSDOM(html);
const doc = dom.window.document;

console.log('what')
.then(arrayBuffer => new TextDecoder('big5').decode(new Uint8Array(arrayBuffer)))
const { document: doc } = parseHTML(html, 'text/html');

const form = doc.querySelector('form[name="register"]');
if(form == null) {
Expand All @@ -194,16 +232,25 @@ export const signInToCCXP = async (studentid: string, password: string): SignInT
throw new Error(LoginError.Unknown);
}

const token = jwt.sign({ sub: studentid, ...data }, process.env.NTHU_HEADLESS_AIS_SIGNING_KEY!, { expiresIn: '15d' });
await cookies().set('accessToken', token, { path: '/', maxAge: 60 * 60 * 24, sameSite: 'strict', secure: true });
// const token = jwt.sign({ sub: studentid, ...data }, process.env.NTHU_HEADLESS_AIS_SIGNING_KEY!, { expiresIn: '15d' });
//use jose to sign the token
const secret = new TextEncoder().encode(process.env.NTHU_HEADLESS_AIS_SIGNING_KEY!);
const jwt = await new jose.SignJWT({ sub: studentid, ...data })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer('NTHUMods')
.setExpirationTime('15d')
.sign(secret);

await cookies().set('accessToken', jwt, { path: '/', maxAge: 60 * 60 * 24, sameSite: 'strict', secure: true });

// Encrypt user password
const encryptedPassword = await encrypt(password);

return { ...result, encryptedPassword };
} catch (err) {
console.error('CCXP Login Err', err);
if(err instanceof Error) return { error: { message: err.message } };
console.error('CCXP Unknown Err', err);
throw err;
}
}
Expand All @@ -226,7 +273,9 @@ export const refreshUserSession = async (studentid: string, encryptedPassword: s
export const getUserSession = async () => {
const accessToken = cookies().get('accessToken')?.value ?? '';
try {
return await jwt.verify(accessToken, process.env.NTHU_HEADLESS_AIS_SIGNING_KEY!) as UserJWT;
const secret = new TextEncoder().encode(process.env.NTHU_HEADLESS_AIS_SIGNING_KEY!);
const { payload } = await jose.jwtVerify(accessToken, secret) as { payload: UserJWT };
return payload;
} catch {
return null;
}
Expand Down
22 changes: 11 additions & 11 deletions src/lib/headless_ais/courses.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
'use server';
import jsdom from 'jsdom';
import iconv from 'iconv-lite';

import { parseHTML } from "linkedom";

export const getStudentCourses = async (ACIXSTORE: string) => {
const baseURL = 'https://www.ccxp.nthu.edu.tw/ccxp/INQUIRE/JH/8/R/6.3/JH8R63002.php?ACIXSTORE=';
const html = await fetch(baseURL + ACIXSTORE)
.then(res => res.arrayBuffer())
.then(arrayBuffer => iconv.decode(Buffer.from(arrayBuffer), 'big5').toString())
const dom = new jsdom.JSDOM(html);
const doc = dom.window.document;
.then(arrayBuffer => new TextDecoder('big5').decode(new Uint8Array(arrayBuffer)))
const window = parseHTML(html);
const doc = window.document;
const table = Array.from(doc.querySelectorAll('table')).find(n => (n.textContent?.trim() ?? "").startsWith('學號 Student Number'))

if(!table) {
Expand Down Expand Up @@ -81,9 +81,9 @@ export const getLatestCourses = async (ACIXSTORE: string) => {
"credentials": "include"
})
.then(res => res.arrayBuffer())
.then(arrayBuffer => iconv.decode(Buffer.from(arrayBuffer), 'big5').toString())
const dom1 = new jsdom.JSDOM(html1);
const doc1 = dom1.window.document;
.then(arrayBuffer => new TextDecoder('big5').decode(new Uint8Array(arrayBuffer)))
const dom1 = parseHTML(html1);
const doc1 = dom1.document;
const semester = Array.from(doc1.querySelectorAll('select')[0].querySelectorAll('option'))[1].value
const phaseArr = Array.from(doc1.querySelectorAll('select')[1].querySelectorAll('option'))
const phase = phaseArr[phaseArr.length - 1].value;
Expand Down Expand Up @@ -111,9 +111,9 @@ export const getLatestCourses = async (ACIXSTORE: string) => {
"credentials": "include"
})
.then(res => res.arrayBuffer())
.then(arrayBuffer => iconv.decode(Buffer.from(arrayBuffer), 'big5').toString())
const dom = new jsdom.JSDOM(html);
const doc = dom.window.document;
.then(arrayBuffer => new TextDecoder('big5').decode(new Uint8Array(arrayBuffer)))
const dom = parseHTML(html);
const doc = dom.document;
const raw_ids = Array.from(doc.querySelectorAll('table')[1].querySelectorAll('tbody > .class3')).map(n => n.children[0].textContent)

return {
Expand Down
9 changes: 4 additions & 5 deletions src/lib/headless_ais/grades.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
'use server';
import jsdom from 'jsdom';
import iconv from 'iconv-lite';
import { parseHTML } from 'linkedom';

export const getStudentGrades = async (ACIXSTORE: string) => {
const baseURL = 'https://www.ccxp.nthu.edu.tw/ccxp/INQUIRE/JH/8/R/6.3/JH8R63002.php?ACIXSTORE=';
const html = await fetch(baseURL + ACIXSTORE)
.then(res => res.arrayBuffer())
.then(arrayBuffer => iconv.decode(Buffer.from(arrayBuffer), 'big5').toString())
const dom = new jsdom.JSDOM(html);
const doc = dom.window.document;
.then(arrayBuffer => new TextDecoder('big5').decode(new Uint8Array(arrayBuffer)))
const dom = parseHTML(html);
const doc = dom.document;
const table = Array.from(doc.querySelectorAll('table')).find(n => (n.textContent?.trim() ?? "").startsWith('學號 Student Number'))

if(!table) {
Expand Down
3 changes: 1 addition & 2 deletions src/types/headless_ais.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { cookies } from "next/headers";
import jwt from 'jsonwebtoken';

export const getServerACIXSTORE = async () => {
const cookie = await cookies();
Expand Down Expand Up @@ -31,4 +30,4 @@ export interface UserJWTDetails {
email: string;
}

export type UserJWT = jwt.JwtPayload & UserJWTDetails;
export type UserJWT = UserJWTDetails;

0 comments on commit aa9c88b

Please sign in to comment.