Feat: Add authentication system and comprehensive usage/CLI documentation

This commit is contained in:
choibk 2026-01-23 21:14:58 +09:00
parent ac92702af1
commit d8c8227c40
25 changed files with 3453 additions and 36 deletions

View File

@ -1,6 +1,10 @@
import { useState, useEffect, useMemo } from 'react';
import axios from 'axios';
import { Key, Plus, Copy, CheckCircle, Clock, Search, Users, ChevronRight, ArrowLeft, Trash2 } from 'lucide-react';
import { Key, Plus, Copy, CheckCircle, Clock, Search, Users, ChevronRight, ArrowLeft, Trash2, LogOut } from 'lucide-react';
import Login from './components/Login';
// Axios settings for cookies
axios.defaults.withCredentials = true;
const API_BASE = import.meta.env.DEV
? 'http://localhost:3006/api'
@ -16,7 +20,9 @@ interface License {
created_at: string;
}
function App() {
const App = () => {
const [user, setUser] = useState<any>(null);
const [authChecked, setAuthChecked] = useState(false);
const [licenses, setLicenses] = useState<License[]>([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
@ -38,18 +44,18 @@ function App() {
// Calculate unique subscribers for the master list
const allSubscribers = useMemo(() => {
return Array.from(new Set(licenses.map(lic => lic.subscriber_id))).sort();
return Array.from(new Set(licenses.map((lic: License) => lic.subscriber_id))).sort();
}, [licenses]);
// Filtered subscribers based on applied search
const filteredSubscribers = useMemo(() => {
if (!appliedSearch.trim()) return allSubscribers;
return allSubscribers.filter(sub => sub.toUpperCase().includes(appliedSearch.toUpperCase()));
return allSubscribers.filter((sub: string) => sub.toUpperCase().includes(appliedSearch.toUpperCase()));
}, [allSubscribers, appliedSearch]);
// Detail view licenses
const detailLicenses = useMemo(() => {
return licenses.filter(lic => lic.subscriber_id === selectedSubscriber);
return licenses.filter((lic: License) => lic.subscriber_id === selectedSubscriber);
}, [licenses, selectedSubscriber]);
const handleSearch = (e?: React.FormEvent) => {
@ -78,9 +84,36 @@ function App() {
};
useEffect(() => {
fetchLicenses();
checkAuth();
}, []);
const checkAuth = async () => {
try {
const res = await axios.get(`${API_BASE}/auth/me`);
setUser(res.data);
fetchLicenses();
} catch (err) {
setUser(null);
} finally {
setAuthChecked(true);
}
};
const handleLogout = async () => {
try {
await axios.post(`${API_BASE}/auth/logout`);
setUser(null);
} catch (err) {
console.error('Logout failed', err);
}
};
useEffect(() => {
if (user) {
fetchLicenses();
}
}, [user]);
const fetchLicenses = async () => {
setLoading(true);
try {
@ -140,6 +173,12 @@ function App() {
if (!authChecked) return null; // Or a loader
if (!user) {
return <Login onLoginSuccess={(userData) => setUser(userData)} apiBase={API_BASE} />;
}
return (
<div className="min-h-screen bg-[#F8FAFC] flex flex-col font-['Pretendard','sans-serif'] text-slate-800">
<header className="bg-white border-b sticky top-0 z-20 shadow-sm">
@ -153,9 +192,22 @@ function App() {
<p className="text-[11px] text-slate-400 font-medium"> </p>
</div>
</div>
<button onClick={fetchLicenses} className="p-2 hover:bg-slate-100 rounded-full transition-all">
<Clock className={`w-5 h-5 text-slate-400 ${loading ? 'animate-spin' : ''}`} />
</button>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-500 text-xs">
{user?.name?.[0] || 'A'}
</div>
<span className="text-sm font-bold text-slate-700">{user?.name}</span>
</div>
<button onClick={handleLogout} className="flex items-center gap-2 text-slate-400 hover:text-red-500 transition-all group">
<span className="text-xs font-bold"></span>
<LogOut className="w-4 h-4" />
</button>
<div className="w-px h-4 bg-slate-200" />
<button onClick={fetchLicenses} className="p-2 hover:bg-slate-100 rounded-full transition-all">
<Clock className={`w-5 h-5 text-slate-400 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
</header>
@ -246,7 +298,7 @@ function App() {
</thead>
<tbody className="divide-y divide-slate-50">
{filteredSubscribers.length > 0 ? (
filteredSubscribers.map((sub, idx) => (
filteredSubscribers.map((sub: string, idx: number) => (
<tr key={sub} className="hover:bg-[#F8FAFF] group transition-colors">
<td className="px-8 py-5 text-xs font-mono text-slate-400 text-center">{idx + 1}</td>
<td className="px-8 py-5 text-sm font-bold text-slate-800">{sub}</td>
@ -297,7 +349,7 @@ function App() {
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{detailLicenses.map((lic, idx) => (
{detailLicenses.map((lic: License, idx: number) => (
<tr key={lic.id} className="hover:bg-[#F8FAFF] transition-colors">
<td className="px-8 py-6 text-xs font-mono text-slate-400 text-center">{idx + 1}</td>
<td className="px-8 py-6">{getStatusBadge(lic.status)}</td>

View File

@ -0,0 +1,113 @@
import React, { useState } from 'react';
import axios from 'axios';
import { Key, Lock, User, AlertCircle, Loader2 } from 'lucide-react';
interface LoginProps {
onLoginSuccess: (user: any) => void;
apiBase: string;
}
const Login: React.FC<LoginProps> = ({ onLoginSuccess, apiBase }) => {
const [loginId, setLoginId] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await axios.post(`${apiBase}/auth/login`, { loginId, password });
if (res.data.success) {
onLoginSuccess(res.data.user);
}
} catch (err: any) {
setError(err.response?.data?.error || '로그인에 실패했습니다.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#F8FAFC] flex items-center justify-center font-['Pretendard','sans-serif'] p-4">
<div className="max-w-md w-full bg-white rounded-3xl shadow-xl border border-slate-200 overflow-hidden transform transition-all">
<div className="p-8">
<div className="flex flex-col items-center mb-8">
<div className="bg-indigo-600 p-4 rounded-2xl shadow-lg shadow-indigo-200 mb-4">
<Key className="text-white w-8 h-8" />
</div>
<h1 className="text-2xl font-black text-slate-800 tracking-tight">License Manager</h1>
<p className="text-sm text-slate-400 font-medium"> </p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider ml-1"></label>
<div className="relative group">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-indigo-500 transition-colors">
<User size={18} />
</div>
<input
type="text"
required
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
placeholder="Admin ID"
className="w-full pl-12 pr-4 py-3.5 bg-slate-50 border border-slate-100 rounded-2xl focus:bg-white focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all text-sm font-medium"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider ml-1"></label>
<div className="relative group">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-indigo-500 transition-colors">
<Lock size={18} />
</div>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full pl-12 pr-4 py-3.5 bg-slate-50 border border-slate-100 rounded-2xl focus:bg-white focus:ring-4 focus:ring-indigo-50 focus:border-indigo-500 outline-none transition-all text-sm font-medium"
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3.5 bg-red-50 text-red-600 rounded-xl animate-shake">
<AlertCircle size={16} className="shrink-0" />
<span className="text-xs font-bold">{error}</span>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-slate-900 text-white font-black py-4 rounded-2xl hover:bg-indigo-600 active:scale-[0.98] transition-all shadow-lg shadow-slate-200 disabled:opacity-50 disabled:scale-100 flex items-center justify-center gap-2 mt-4"
>
{loading ? (
<>
<Loader2 size={20} className="animate-spin" />
<span> ...</span>
</>
) : (
<span> </span>
)}
</button>
</form>
</div>
<div className="bg-slate-50 p-6 text-center border-t border-slate-100">
<p className="text-[11px] text-slate-400 font-medium tracking-wide">
© 2024 Smart IMS Platform. All Rights Reserved.
</p>
</div>
</div>
</div>
);
};
export default Login;

View File

@ -37,6 +37,7 @@ npm run build
### 3. 서버 환경 설정
`server/.env` 파일을 NAS 운영 환경에 맞게 수정합니다.
- `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` 설정
- `JWT_SECRET`: 보안을 위한 임의의 긴 문자열 입력
- 포트 번호 확인 (기본 `3006`)
### 4. 의존성 설치 및 실행

View File

@ -19,6 +19,10 @@
- `/server`: Express 기반 백엔드 API 서버
- `/config`: 암호화 키(`private_key.pem`, `public_key.pem`) 보관
- `/docs`: 배포 및 운영 관련 문서
- `overview.md`: 시스템 개요
- `deployment.md`: NAS 배포 가이드 (PM2/작업스케줄러 포함)
- `usage.md`: 웹 UI 사용법 및 CLI 명령어 가이드
## 시작하기
상세한 설치 및 배포 방법은 [배포 가이드](./deployment.md)를 참조하세요.
- 설치 및 배포: [배포 가이드](./deployment.md)
- 시스템 사용 및 명령어: [사용 가이드](./usage.md)

81
docs/usage.md Normal file
View File

@ -0,0 +1,81 @@
# 라이선스 관리 시스템 사용 가이드 (Usage Guide)
이 문서는 라이선스 관리 시스템을 웹 UI 및 터미널(CLI)에서 사용하는 방법을 설명합니다.
## 1. 웹 관리 UI 사용법
시스템 배포 후 브라우저를 통해 접속하여 라이선스를 관리할 수 있습니다.
### 로그인
- **주소**: `http://[NAS-IP 또는 도메인]:3006`
- **초기 계정**: `admin` / `^Ocean1472bk`
### 라이선스 신규 발급
1. 왼쪽 **'라이선스 신규 발급'** 패널에서 정보를 입력합니다.
- **모듈 시스템**: 자산관리, 생산관리, 모니터링 중 선택
- **유형**: SUB(구독형), DEMO(체험판), DEV(영구 프로젝트용)
- **구독자 ID**: 고객사 식별 ID (예: `SOKUREE-2024-01`)
- **만기 일자**: 라이선스 종료일 지정
2. **'라이선스 발급'** 버튼을 클릭합니다.
### 라이선스 관리 및 복사
- **검색**: 상단 검색바를 통해 특정 구독자 ID를 조회할 수 있습니다.
- **상태 확인**: `등록 대기`(발급됨), `활성화됨`(Smart IMS 등록됨) 상태를 확인합니다.
- **키 복사**: 발급된 라이선스 키의 아이콘을 클릭하여 클립보드에 복사할 수 있습니다.
- **삭제**: 필요 없는 라이선스는 쓰레기통 아이콘으로 삭제 가능합니다.
---
## 2. 터미널(CLI) 작업 가이드
개발자 또는 관리자가 NAS 터미널(SSH)에서 시스템을 점검하거나 제어할 때 사용합니다.
### 작업 위치
모든 작업은 프로젝트 루트 디렉토리 및 서버 디렉토리에서 수행합니다.
- **기본 위치**: `/volume1/[사용자폴더]/smart_ims_license`
- **서버 소스**: `/volume1/[사용자폴더]/smart_ims_license/server`
### 주요 관리 명령어
#### 서비스 상태 확인 (PM2)
```bash
# 전체 서비스 리스트 확인
pm2 list
# 실시간 로그 모니터링
pm2 logs license-manager
# 서비스 재시작 (코드 수정 반영 등)
pm2 restart license-manager
```
#### DB 초기화 및 관리
수동으로 DB를 초기화하거나 스키마를 업데이트해야 할 경우 사용합니다.
(작업 위치: `server/`)
```bash
# DB 스키마 생성 및 초기 관리자 생성
node init_db.js
# 테스트 데이터 초기화 (주의: 기존 데이터 삭제될 수 있음)
node reset_test_data.js
```
#### 환경 변수 수정
DB 접속 정보나 포트를 변경해야 할 경우:
```bash
# vi 또는 nano 편집기로 .env 수정
vi .env
```
*(수정 후 `pm2 restart license-manager` 필수)*
### 수동 라이선스 생성 (CLI 도구)
웹 UI 없이 터미널에서 즉시 라이선스 키를 생성하고 싶을 때:
`server/debug_license.js` 등의 도구를 활용할 수 있습니다 (추가 구현 시 제공).
---
## 3. 문제 해결 (Troubleshooting)
- **로그인 실패**: `server/.env``JWT_SECRET`이 설정되어 있는지 확인하세요.
- **DB 연결 오류**: `server/.env`의 DB 설정 정보와 Synology MariaDB 10의 정보가 일치하는지 확인하세요.
- **웹 페이지 접속 안됨**: Synology 방화벽에서 3006 포트가 허용되어 있는지, PM2 서비스가 `online` 상태인지 확인하세요.

Binary file not shown.

View File

@ -40,29 +40,32 @@ async function init() {
console.log('Skip issued_licenses migration:', e.message);
}
// 2. license_history
const createHistorySql = `
CREATE TABLE IF NOT EXISTS license_history (
// 3. users table
const createUsersSql = `
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
module_code VARCHAR(50) NOT NULL,
license_key TEXT,
license_type VARCHAR(20),
subscriber_id VARCHAR(50),
activated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
login_id VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
name VARCHAR(100),
role VARCHAR(20) DEFAULT 'admin',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`;
await connection.query(createHistorySql);
await connection.query(createUsersSql);
try {
await connection.query(`
INSERT IGNORE INTO smart_ims_license_db.license_history
(module_code, license_key, license_type, subscriber_id, activated_at)
SELECT module_code, license_key, license_type, subscriber_id, activated_at
FROM sokuree_platform_dev.license_history;
`);
console.log('✅ Migrated license_history');
} catch (e) {
console.log('Skip license_history migration:', e.message);
// Seed Initial Admin
const bcrypt = require('bcryptjs');
const adminId = 'admin';
const adminPw = '^Ocean1472bk';
const [existing] = await connection.query('SELECT id FROM users WHERE login_id = ?', [adminId]);
if (existing.length === 0) {
const hashedPw = await bcrypt.hash(adminPw, 10);
await connection.query(
'INSERT INTO users (login_id, password, name, role) VALUES (?, ?, ?, ?)',
[adminId, hashedPw, 'Administrator', 'admin']
);
console.log('✅ Created initial admin account');
}
} catch (err) {

16
server/node_modules/.bin/bcrypt generated vendored Normal file
View File

@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../bcryptjs/bin/bcrypt" "$@"
else
exec node "$basedir/../bcryptjs/bin/bcrypt" "$@"
fi

17
server/node_modules/.bin/bcrypt.cmd generated vendored Normal file
View File

@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\bcryptjs\bin\bcrypt" %*

28
server/node_modules/.bin/bcrypt.ps1 generated vendored Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../bcryptjs/bin/bcrypt" $args
} else {
& "$basedir/node$exe" "$basedir/../bcryptjs/bin/bcrypt" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../bcryptjs/bin/bcrypt" $args
} else {
& "node$exe" "$basedir/../bcryptjs/bin/bcrypt" $args
}
$ret=$LASTEXITCODE
}
exit $ret

View File

@ -47,6 +47,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",

27
server/node_modules/bcryptjs/LICENSE generated vendored Normal file
View File

@ -0,0 +1,27 @@
bcrypt.js
---------
Copyright (c) 2012 Nevins Bartolomeo <nevins.bartolomeo@gmail.com>
Copyright (c) 2012 Shane Girish <shaneGirish@gmail.com>
Copyright (c) 2025 Daniel Wirtz <dcode@dcode.io>
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. The name of the author may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

201
server/node_modules/bcryptjs/README.md generated vendored Normal file
View File

@ -0,0 +1,201 @@
# bcrypt.js
Optimized bcrypt in JavaScript with zero dependencies, with TypeScript support. Compatible to the C++
[bcrypt](https://npmjs.org/package/bcrypt) binding on Node.js and also working in the browser.
[![Build Status](https://img.shields.io/github/actions/workflow/status/dcodeIO/bcrypt.js/test.yml?branch=main&label=test&logo=github)](https://github.com/dcodeIO/bcrypt.js/actions/workflows/test.yml) [![Publish Status](https://img.shields.io/github/actions/workflow/status/dcodeIO/bcrypt.js/publish.yml?branch=main&label=publish&logo=github)](https://github.com/dcodeIO/bcrypt.js/actions/workflows/publish.yml) [![npm](https://img.shields.io/npm/v/bcryptjs.svg?label=npm&color=007acc&logo=npm)](https://www.npmjs.com/package/bcryptjs)
## Security considerations
Besides incorporating a salt to protect against rainbow table attacks, bcrypt is an adaptive function: over time, the
iteration count can be increased to make it slower, so it remains resistant to brute-force search attacks even with
increasing computation power. ([see](http://en.wikipedia.org/wiki/Bcrypt))
While bcrypt.js is compatible to the C++ bcrypt binding, it is written in pure JavaScript and thus slower ([about 30%](https://github.com/dcodeIO/bcrypt.js/wiki/Benchmark)), effectively reducing the number of iterations that can be
processed in an equal time span.
The maximum input length is 72 bytes (note that UTF-8 encoded characters use up to 4 bytes) and the length of generated
hashes is 60 characters. Note that maximum input length is not implicitly checked by the library for compatibility with
the C++ binding on Node.js, but should be checked with `bcrypt.truncates(password)` where necessary.
## Usage
The package exports an ECMAScript module with an UMD fallback.
```
$> npm install bcryptjs
```
```ts
import bcrypt from "bcryptjs";
```
### Usage with a CDN
- From GitHub via [jsDelivr](https://www.jsdelivr.com):<br />
`https://cdn.jsdelivr.net/gh/dcodeIO/bcrypt.js@TAG/index.js` (ESM)
- From npm via [jsDelivr](https://www.jsdelivr.com):<br />
`https://cdn.jsdelivr.net/npm/bcryptjs@VERSION/index.js` (ESM)<br />
`https://cdn.jsdelivr.net/npm/bcryptjs@VERSION/umd/index.js` (UMD)
- From npm via [unpkg](https://unpkg.com):<br />
`https://unpkg.com/bcryptjs@VERSION/index.js` (ESM)<br />
`https://unpkg.com/bcryptjs@VERSION/umd/index.js` (UMD)
Replace `TAG` respectively `VERSION` with a [specific version](https://github.com/dcodeIO/bcrypt.js/releases) or omit it (not recommended in production) to use latest.
When using the ESM variant in a browser, the `crypto` import needs to be stubbed out, for example using an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap). Bundlers should omit it automatically.
### Usage - Sync
To hash a password:
```ts
const salt = bcrypt.genSaltSync(10);
const hash = bcrypt.hashSync("B4c0/\/", salt);
// Store hash in your password DB
```
To check a password:
```ts
// Load hash from your password DB
bcrypt.compareSync("B4c0/\/", hash); // true
bcrypt.compareSync("not_bacon", hash); // false
```
Auto-gen a salt and hash:
```ts
const hash = bcrypt.hashSync("bacon", 10);
```
### Usage - Async
To hash a password:
```ts
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash("B4c0/\/", salt);
// Store hash in your password DB
```
```ts
bcrypt.genSalt(10, (err, salt) => {
bcrypt.hash("B4c0/\/", salt, function (err, hash) {
// Store hash in your password DB
});
});
```
To check a password:
```ts
// Load hash from your password DB
await bcrypt.compare("B4c0/\/", hash); // true
await bcrypt.compare("not_bacon", hash); // false
```
```ts
// Load hash from your password DB
bcrypt.compare("B4c0/\/", hash, (err, res) => {
// res === true
});
bcrypt.compare("not_bacon", hash, (err, res) => {
// res === false
});
```
Auto-gen a salt and hash:
```ts
await bcrypt.hash("B4c0/\/", 10);
// Store hash in your password DB
```
```ts
bcrypt.hash("B4c0/\/", 10, (err, hash) => {
// Store hash in your password DB
});
```
**Note:** Under the hood, asynchronous APIs split an operation into small chunks. After the completion of a chunk, the execution of the next chunk is placed on the back of the [JS event queue](https://developer.mozilla.org/en/docs/Web/JavaScript/EventLoop), efficiently yielding for other computation to execute.
### Usage - Command Line
```
Usage: bcrypt <input> [rounds|salt]
```
## API
### Callback types
- **Callback<`T`>**: `(err: Error | null, result?: T) => void`<br />
Called with an error on failure or a value of type `T` upon success.
- **ProgressCallback**: `(percentage: number) => void`<br />
Called with the percentage of rounds completed (0.0 - 1.0), maximally once per `MAX_EXECUTION_TIME = 100` ms.
- **RandomFallback**: `(length: number) => number[]`<br />
Called to obtain random bytes when both [Web Crypto API](http://www.w3.org/TR/WebCryptoAPI/) and Node.js
[crypto](http://nodejs.org/api/crypto.html) are not available.
### Functions
- bcrypt.**genSaltSync**(rounds?: `number`): `string`<br />
Synchronously generates a salt. Number of rounds defaults to 10 when omitted.
- bcrypt.**genSalt**(rounds?: `number`): `Promise<string>`<br />
Asynchronously generates a salt. Number of rounds defaults to 10 when omitted.
- bcrypt.**genSalt**([rounds: `number`, ]callback: `Callback<string>`): `void`<br />
Asynchronously generates a salt. Number of rounds defaults to 10 when omitted.
- bcrypt.**truncates**(password: `string`): `boolean`<br />
Tests if a password will be truncated when hashed, that is its length is greater than 72 bytes when converted to UTF-8.
- bcrypt.**hashSync**(password: `string`, salt?: `number | string`): `string`
Synchronously generates a hash for the given password. Number of rounds defaults to 10 when omitted.
- bcrypt.**hash**(password: `string`, salt: `number | string`): `Promise<string>`<br />
Asynchronously generates a hash for the given password.
- bcrypt.**hash**(password: `string`, salt: `number | string`, callback: `Callback<string>`, progressCallback?: `ProgressCallback`): `void`<br />
Asynchronously generates a hash for the given password.
- bcrypt.**compareSync**(password: `string`, hash: `string`): `boolean`<br />
Synchronously tests a password against a hash.
- bcrypt.**compare**(password: `string`, hash: `string`): `Promise<boolean>`<br />
Asynchronously compares a password against a hash.
- bcrypt.**compare**(password: `string`, hash: `string`, callback: `Callback<boolean>`, progressCallback?: `ProgressCallback`)<br />
Asynchronously compares a password against a hash.
- bcrypt.**getRounds**(hash: `string`): `number`<br />
Gets the number of rounds used to encrypt the specified hash.
- bcrypt.**getSalt**(hash: `string`): `string`<br />
Gets the salt portion from a hash. Does not validate the hash.
- bcrypt.**setRandomFallback**(random: `RandomFallback`): `void`<br />
Sets the pseudo random number generator to use as a fallback if neither [Web Crypto API](http://www.w3.org/TR/WebCryptoAPI/) nor Node.js [crypto](http://nodejs.org/api/crypto.html) are available. Please note: It is highly important that the PRNG used is cryptographically secure and that it is seeded properly!
## Building
Building the UMD fallback:
```
$> npm run build
```
Running the [tests](./tests):
```
$> npm test
```
## Credits
Based on work started by Shane Girish at [bcrypt-nodejs](https://github.com/shaneGirish/bcrypt-nodejs), which is itself
based on [javascript-bcrypt](http://code.google.com/p/javascript-bcrypt/) (New BSD-licensed).

23
server/node_modules/bcryptjs/bin/bcrypt generated vendored Normal file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env node
import path from "node:path";
import bcrypt from "../index.js";
if (process.argv.length < 3) {
console.log(
"Usage: " + path.basename(process.argv[1]) + " <input> [rounds|salt]",
);
process.exit(1);
} else {
var salt;
if (process.argv.length > 3) {
salt = process.argv[3];
var rounds = parseInt(salt, 10);
if (rounds == salt) {
salt = bcrypt.genSaltSync(rounds);
}
} else {
salt = bcrypt.genSaltSync();
}
console.log(bcrypt.hashSync(process.argv[2], salt));
}

3
server/node_modules/bcryptjs/index.d.ts generated vendored Normal file
View File

@ -0,0 +1,3 @@
import * as bcrypt from "./types.js";
export * from "./types.js";
export default bcrypt;

1159
server/node_modules/bcryptjs/index.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

76
server/node_modules/bcryptjs/package.json generated vendored Normal file
View File

@ -0,0 +1,76 @@
{
"name": "bcryptjs",
"description": "Optimized bcrypt in plain JavaScript with zero dependencies, with TypeScript support. Compatible to 'bcrypt'.",
"version": "3.0.3",
"author": "Daniel Wirtz <dcode@dcode.io>",
"contributors": [
"Shane Girish <shaneGirish@gmail.com> (https://github.com/shaneGirish)",
"Alex Murray <> (https://github.com/alexmurray)",
"Nicolas Pelletier <> (https://github.com/NicolasPelletier)",
"Josh Rogers <> (https://github.com/geekymole)",
"Noah Isaacson <noah@nisaacson.com> (https://github.com/nisaacson)"
],
"repository": {
"type": "url",
"url": "https://github.com/dcodeIO/bcrypt.js.git"
},
"bugs": {
"url": "https://github.com/dcodeIO/bcrypt.js/issues"
},
"keywords": [
"bcrypt",
"password",
"auth",
"authentication",
"encryption",
"crypt",
"crypto"
],
"type": "module",
"main": "umd/index.js",
"types": "umd/index.d.ts",
"exports": {
".": {
"import": {
"types": "./index.d.ts",
"default": "./index.js"
},
"require": {
"types": "./umd/index.d.ts",
"default": "./umd/index.js"
}
}
},
"bin": {
"bcrypt": "bin/bcrypt"
},
"license": "BSD-3-Clause",
"scripts": {
"build": "node scripts/build.js",
"lint": "prettier --check .",
"format": "prettier --write .",
"test": "npm run test:unit && npm run test:typescript",
"test:unit": "node tests",
"test:typescript": "tsc --project tests/typescript/tsconfig.esnext.json && tsc --project tests/typescript/tsconfig.nodenext.json && tsc --project tests/typescript/tsconfig.commonjs.json && tsc --project tests/typescript/tsconfig.global.json"
},
"files": [
"index.js",
"index.d.ts",
"types.d.ts",
"umd/index.js",
"umd/index.d.ts",
"umd/types.d.ts",
"umd/package.json",
"LICENSE",
"README.md"
],
"browser": {
"crypto": false
},
"devDependencies": {
"bcrypt": "^5.1.1",
"esm2umd": "^0.3.1",
"prettier": "^3.5.0",
"typescript": "^5.7.3"
}
}

157
server/node_modules/bcryptjs/types.d.ts generated vendored Normal file
View File

@ -0,0 +1,157 @@
// Originally imported from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/8b36dbdf95b624b8a7cd7f8416f06c15d274f9e6/types/bcryptjs/index.d.ts
// MIT license.
/** Called with an error on failure or a value of type `T` upon success. */
type Callback<T> = (err: Error | null, result?: T) => void;
/** Called with the percentage of rounds completed (0.0 - 1.0), maximally once per `MAX_EXECUTION_TIME = 100` ms. */
type ProgressCallback = (percentage: number) => void;
/** Called to obtain random bytes when both Web Crypto API and Node.js crypto are not available. */
type RandomFallback = (length: number) => number[];
/**
* Sets the pseudo random number generator to use as a fallback if neither node's crypto module nor the Web Crypto API is available.
* Please note: It is highly important that the PRNG used is cryptographically secure and that it is seeded properly!
* @param random Function taking the number of bytes to generate as its sole argument, returning the corresponding array of cryptographically secure random byte values.
*/
export declare function setRandomFallback(random: RandomFallback): void;
/**
* Synchronously generates a salt.
* @param rounds Number of rounds to use, defaults to 10 if omitted
* @return Resulting salt
* @throws If a random fallback is required but not set
*/
export declare function genSaltSync(rounds?: number): string;
/**
* Asynchronously generates a salt.
* @param rounds Number of rounds to use, defaults to 10 if omitted
* @return Promise with resulting salt, if callback has been omitted
*/
export declare function genSalt(rounds?: number): Promise<string>;
/**
* Asynchronously generates a salt.
* @param callback Callback receiving the error, if any, and the resulting salt
*/
export declare function genSalt(callback: Callback<string>): void;
/**
* Asynchronously generates a salt.
* @param rounds Number of rounds to use, defaults to 10 if omitted
* @param callback Callback receiving the error, if any, and the resulting salt
*/
export declare function genSalt(
rounds: number,
callback: Callback<string>,
): void;
/**
* Synchronously generates a hash for the given password.
* @param password Password to hash
* @param salt Salt length to generate or salt to use, default to 10
* @return Resulting hash
*/
export declare function hashSync(
password: string,
salt?: number | string,
): string;
/**
* Asynchronously generates a hash for the given password.
* @param password Password to hash
* @param salt Salt length to generate or salt to use
* @return Promise with resulting hash, if callback has been omitted
*/
export declare function hash(
password: string,
salt: number | string,
): Promise<string>;
/**
* Asynchronously generates a hash for the given password.
* @param password Password to hash
* @param salt Salt length to generate or salt to use
* @param callback Callback receiving the error, if any, and the resulting hash
* @param progressCallback Callback successively called with the percentage of rounds completed (0.0 - 1.0), maximally once per MAX_EXECUTION_TIME = 100 ms.
*/
export declare function hash(
password: string,
salt: number | string,
callback?: Callback<string>,
progressCallback?: ProgressCallback,
): void;
/**
* Synchronously tests a password against a hash.
* @param password Password to test
* @param hash Hash to test against
* @return true if matching, otherwise false
*/
export declare function compareSync(password: string, hash: string): boolean;
/**
* Asynchronously tests a password against a hash.
* @param password Password to test
* @param hash Hash to test against
* @return Promise, if callback has been omitted
*/
export declare function compare(
password: string,
hash: string,
): Promise<boolean>;
/**
* Asynchronously tests a password against a hash.
* @param password Password to test
* @param hash Hash to test against
* @param callback Callback receiving the error, if any, otherwise the result
* @param progressCallback Callback successively called with the percentage of rounds completed (0.0 - 1.0), maximally once per MAX_EXECUTION_TIME = 100 ms.
*/
export declare function compare(
password: string,
hash: string,
callback?: Callback<boolean>,
progressCallback?: ProgressCallback,
): void;
/**
* Gets the number of rounds used to encrypt the specified hash.
* @param hash Hash to extract the used number of rounds from
* @return Number of rounds used
*/
export declare function getRounds(hash: string): number;
/**
* Gets the salt portion from a hash. Does not validate the hash.
* @param hash Hash to extract the salt from
* @return Extracted salt part
*/
export declare function getSalt(hash: string): string;
/**
* Tests if a password will be truncated when hashed, that is its length is
* greater than 72 bytes when converted to UTF-8.
* @param password The password to test
* @returns `true` if truncated, otherwise `false`
*/
export declare function truncates(password: string): boolean;
/**
* Encodes a byte array to base64 with up to len bytes of input, using the custom bcrypt alphabet.
* @function
* @param b Byte array
* @param len Maximum input length
*/
export declare function encodeBase64(
b: Readonly<ArrayLike<number>>,
len: number,
): string;
/**
* Decodes a base64 encoded string to up to len bytes of output, using the custom bcrypt alphabet.
* @function
* @param s String to decode
* @param len Maximum output length
*/
export declare function decodeBase64(s: string, len: number): number[];

3
server/node_modules/bcryptjs/umd/index.d.ts generated vendored Normal file
View File

@ -0,0 +1,3 @@
import * as bcrypt from "./types.js";
export = bcrypt;
export as namespace bcrypt;

1220
server/node_modules/bcryptjs/umd/index.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

3
server/node_modules/bcryptjs/umd/package.json generated vendored Normal file
View File

@ -0,0 +1,3 @@
{
"type": "commonjs"
}

157
server/node_modules/bcryptjs/umd/types.d.ts generated vendored Normal file
View File

@ -0,0 +1,157 @@
// Originally imported from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/8b36dbdf95b624b8a7cd7f8416f06c15d274f9e6/types/bcryptjs/index.d.ts
// MIT license.
/** Called with an error on failure or a value of type `T` upon success. */
type Callback<T> = (err: Error | null, result?: T) => void;
/** Called with the percentage of rounds completed (0.0 - 1.0), maximally once per `MAX_EXECUTION_TIME = 100` ms. */
type ProgressCallback = (percentage: number) => void;
/** Called to obtain random bytes when both Web Crypto API and Node.js crypto are not available. */
type RandomFallback = (length: number) => number[];
/**
* Sets the pseudo random number generator to use as a fallback if neither node's crypto module nor the Web Crypto API is available.
* Please note: It is highly important that the PRNG used is cryptographically secure and that it is seeded properly!
* @param random Function taking the number of bytes to generate as its sole argument, returning the corresponding array of cryptographically secure random byte values.
*/
export declare function setRandomFallback(random: RandomFallback): void;
/**
* Synchronously generates a salt.
* @param rounds Number of rounds to use, defaults to 10 if omitted
* @return Resulting salt
* @throws If a random fallback is required but not set
*/
export declare function genSaltSync(rounds?: number): string;
/**
* Asynchronously generates a salt.
* @param rounds Number of rounds to use, defaults to 10 if omitted
* @return Promise with resulting salt, if callback has been omitted
*/
export declare function genSalt(rounds?: number): Promise<string>;
/**
* Asynchronously generates a salt.
* @param callback Callback receiving the error, if any, and the resulting salt
*/
export declare function genSalt(callback: Callback<string>): void;
/**
* Asynchronously generates a salt.
* @param rounds Number of rounds to use, defaults to 10 if omitted
* @param callback Callback receiving the error, if any, and the resulting salt
*/
export declare function genSalt(
rounds: number,
callback: Callback<string>,
): void;
/**
* Synchronously generates a hash for the given password.
* @param password Password to hash
* @param salt Salt length to generate or salt to use, default to 10
* @return Resulting hash
*/
export declare function hashSync(
password: string,
salt?: number | string,
): string;
/**
* Asynchronously generates a hash for the given password.
* @param password Password to hash
* @param salt Salt length to generate or salt to use
* @return Promise with resulting hash, if callback has been omitted
*/
export declare function hash(
password: string,
salt: number | string,
): Promise<string>;
/**
* Asynchronously generates a hash for the given password.
* @param password Password to hash
* @param salt Salt length to generate or salt to use
* @param callback Callback receiving the error, if any, and the resulting hash
* @param progressCallback Callback successively called with the percentage of rounds completed (0.0 - 1.0), maximally once per MAX_EXECUTION_TIME = 100 ms.
*/
export declare function hash(
password: string,
salt: number | string,
callback?: Callback<string>,
progressCallback?: ProgressCallback,
): void;
/**
* Synchronously tests a password against a hash.
* @param password Password to test
* @param hash Hash to test against
* @return true if matching, otherwise false
*/
export declare function compareSync(password: string, hash: string): boolean;
/**
* Asynchronously tests a password against a hash.
* @param password Password to test
* @param hash Hash to test against
* @return Promise, if callback has been omitted
*/
export declare function compare(
password: string,
hash: string,
): Promise<boolean>;
/**
* Asynchronously tests a password against a hash.
* @param password Password to test
* @param hash Hash to test against
* @param callback Callback receiving the error, if any, otherwise the result
* @param progressCallback Callback successively called with the percentage of rounds completed (0.0 - 1.0), maximally once per MAX_EXECUTION_TIME = 100 ms.
*/
export declare function compare(
password: string,
hash: string,
callback?: Callback<boolean>,
progressCallback?: ProgressCallback,
): void;
/**
* Gets the number of rounds used to encrypt the specified hash.
* @param hash Hash to extract the used number of rounds from
* @return Number of rounds used
*/
export declare function getRounds(hash: string): number;
/**
* Gets the salt portion from a hash. Does not validate the hash.
* @param hash Hash to extract the salt from
* @return Extracted salt part
*/
export declare function getSalt(hash: string): string;
/**
* Tests if a password will be truncated when hashed, that is its length is
* greater than 72 bytes when converted to UTF-8.
* @param password The password to test
* @returns `true` if truncated, otherwise `false`
*/
export declare function truncates(password: string): boolean;
/**
* Encodes a byte array to base64 with up to len bytes of input, using the custom bcrypt alphabet.
* @function
* @param b Byte array
* @param len Maximum input length
*/
export declare function encodeBase64(
b: Readonly<ArrayLike<number>>,
len: number,
): string;
/**
* Decodes a base64 encoded string to up to len bytes of output, using the custom bcrypt alphabet.
* @function
* @param s String to decode
* @param len Maximum output length
*/
export declare function decodeBase64(s: string, len: number): number[];

View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"crypto": "^1.0.1",
@ -64,6 +65,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",

View File

@ -13,6 +13,7 @@
"license": "ISC",
"type": "commonjs",
"dependencies": {
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"crypto": "^1.0.1",
@ -24,4 +25,4 @@
"devDependencies": {
"nodemon": "^3.1.11"
}
}
}

View File

@ -6,15 +6,32 @@ require('dotenv').config();
const db = require('./db');
const { generateLicense } = require('./licenseManager');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const cookieParser = require('cookie-parser');
const app = express();
const PORT = process.env.LICENSE_SERVER_PORT || 3006;
const JWT_SECRET = process.env.JWT_SECRET || 'fallback_secret';
app.use(cors({
origin: true,
credentials: true
}));
app.use(express.json());
app.use(cookieParser());
// Auth Middleware
const authenticateToken = (req, res, next) => {
const token = req.cookies.token;
if (!token) return res.status(401).json({ error: 'Unauthorized: No token provided' });
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Forbidden: Invalid token' });
req.user = user;
next();
});
};
// Load Private Key for Generation
const privateKeyPath = path.join(__dirname, '../config/private_key.pem');
@ -29,8 +46,44 @@ try {
console.error('Error loading private key:', e);
}
// --- Auth Routes ---
app.post('/api/auth/login', async (req, res) => {
const { loginId, password } = req.body;
try {
const [users] = await db.query('SELECT * FROM users WHERE login_id = ?', [loginId]);
if (users.length === 0) return res.status(401).json({ error: 'Invalid ID or Password' });
const user = users[0];
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) return res.status(401).json({ error: 'Invalid ID or Password' });
const token = jwt.sign({ id: user.id, loginId: user.login_id, name: user.name }, JWT_SECRET, { expiresIn: '24h' });
res.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
res.json({ success: true, user: { id: user.id, loginId: user.login_id, name: user.name } });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Login failed' });
}
});
app.get('/api/auth/me', authenticateToken, (req, res) => {
res.json(req.user);
});
app.post('/api/auth/logout', (req, res) => {
res.clearCookie('token');
res.json({ success: true });
});
// --- License Routes ---
// 1. Get All Issued Licenses
app.get('/api/licenses', async (req, res) => {
app.get('/api/licenses', authenticateToken, async (req, res) => {
try {
const [rows] = await db.query('SELECT * FROM issued_licenses ORDER BY created_at DESC');
res.json(rows);
@ -41,7 +94,7 @@ app.get('/api/licenses', async (req, res) => {
});
// 1.1 Delete License
app.delete('/api/licenses/:id', async (req, res) => {
app.delete('/api/licenses/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
await db.query('DELETE FROM issued_licenses WHERE id = ?', [id]);
@ -70,7 +123,7 @@ app.post('/api/licenses/activate', async (req, res) => {
});
// 2. Generate New License
app.post('/api/licenses/generate', async (req, res) => {
app.post('/api/licenses/generate', authenticateToken, async (req, res) => {
const { moduleCode, type, subscriberId, expiryDate } = req.body;
if (!moduleCode || !type || !subscriberId) {
@ -113,7 +166,7 @@ app.post('/api/licenses/generate', async (req, res) => {
const clientDistPath = path.join(__dirname, '../client/dist');
if (fs.existsSync(clientDistPath)) {
app.use(express.static(clientDistPath));
// SPA Fallback: Any route not handled by API should return index.html
app.get('*', (req, res) => {
if (!req.path.startsWith('/api')) {