Initialize

This commit is contained in:
2025-10-24 22:07:49 +08:00
commit 233f3fe40e
13 changed files with 1580 additions and 0 deletions

11
.dockerignore Executable file
View File

@@ -0,0 +1,11 @@
.git
.gitignore
__pycache__
*.pyc
*.pyo
*.pyd
venv
.venv
.env
node_modules

144
.gitignore vendored Normal file
View File

@@ -0,0 +1,144 @@
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node

10
Dockerfile Executable file
View File

@@ -0,0 +1,10 @@
FROM node:22.19-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]
EXPOSE 3000

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# tutorotp
MobilePASS 好醜,而且他是 HOTP 不是 TOTP

18
docker-compose.yml Executable file
View File

@@ -0,0 +1,18 @@
services:
tutorotp:
build: .
image: pinlin/tutorotp:latest
environment:
- NODE_ENV=production
- PORT=3000
- PGHOST=
- PGPORT=5432
- PGUSER=tutorotp
- PGPASSWORD=
- PGDATABASE=tutorotp
- ACTIVATION_CODE=
- OIDC_WELL_KNOWN_URL=
- CLIENT_ID=
- CLIENT_SECRET=
- COOKIE_SECRET=
restart: always

13
example.env Normal file
View File

@@ -0,0 +1,13 @@
NODE_ENV=production
PORT=3000
PGHOST=
PGPORT=5432
PGUSER=tutorotp
PGPASSWORD=
PGDATABASE=tutorotp
ACTIVATION_CODE=
OIDC_WELL_KNOWN_URL=
CLIENT_ID=
CLIENT_SECRET=
REDIRECT_URI=http://127.0.0.1:3000/callback
COOKIE_SECRET=

146
index.js Executable file
View File

@@ -0,0 +1,146 @@
const express = require("express");
const session = require("express-session");
const { Issuer } = require("openid-client");
const { createTemplateRenderer } = require("./utils/templates.js");
const { Client } = require("pg");
const { generateOtp } = require("mobilepass");
require("dotenv").config();
const PORT = process.env.PORT || 3000;
const app = express();
app.use(express.json());
app.use(
session({
secret: process.env.COOKIE_SECRET,
saveUninitialized: false,
cookie: { maxAge: 10 * 60 * 1000 },
})
);
const renderHtml = createTemplateRenderer({
useCache: process.env.NODE_ENV === "production",
});
(async () => {
const issuer = await Issuer.discover(process.env.OIDC_WELL_KNOWN_URL);
const client = new issuer.Client({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
response_types: ["code"],
});
const pg = new Client();
await pg.connect();
await pg.query(`
CREATE TABLE IF NOT EXISTS counters (
id SERIAL PRIMARY KEY,
counter INTEGER NOT NULL
)
`);
app.get("/", async (req, res) => {
if (req.session.user) {
const counter = await pg.query(
"SELECT counter FROM counters WHERE id = 1"
).then((result) => {
if (result.rows.length > 0) {
return result.rows[0].counter;
} else {
return 0;
}
});
res.send(
renderHtml("index.html", {
USERNAME: req.session.user.sub,
OTP: await generateOtp(process.env.ACTIVATION_CODE, counter),
COUNTER: counter,
})
);
} else {
res.send(renderHtml("login.html"));
}
});
const stateMap = {};
app.post("/authorize", (req, res) => {
const { redirectUri } = req.body;
if (!redirectUri) {
return res.send(
renderHtml("login-fail.html", {
ERROR: "redirectUri missing or invalid",
})
);
}
const state = Math.random().toString(36).substring(2);
stateMap[state] = { redirectUri };
const url = client.authorizationUrl({
scope: "openid profile",
redirect_uri: redirectUri,
state,
});
// 回傳 JSON 給前端,讓前端負責導向
res.json({ redirect: url });
});
app.get("/callback", async (req, res) => {
try {
const state = req.query.state;
const { redirectUri } = stateMap[state];
const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params, { state });
const userinfo = await client.userinfo(tokenSet.access_token);
req.session.user = userinfo;
res.redirect("/");
} catch (error) {
res.send(renderHtml("login-fail.html", { ERROR: error }));
}
});
app.put("/counter", (req, res) => {
if (req.session.user) {
const { counter } = req.body;
try {
pg.query(
`
INSERT INTO counters (id, counter)
VALUES (1, $1)
ON CONFLICT (id) DO UPDATE SET counter = $1
`,
[counter]
);
res.json({ success: true });
}
catch (error) {
res.status(500).json({ success: false, error: error.message });
}
} else {
res.status(401).json({ success: false, error: "Unauthorized" });
}
});
app.post("/logout", (req, res) => {
req.session.destroy(() => {
res.redirect("/");
});
});
process.on("SIGINT", async () => {
await pg.end();
console.log("Database connection closed.");
process.exit(0);
});
app.listen(PORT, () => {
console.log(`listening at http://localhost:${PORT}`);
});
})();

1058
package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

16
package.json Executable file
View File

@@ -0,0 +1,16 @@
{
"name": "tutorotp",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-session": "^1.18.1",
"mobilepass": "^0.0.1",
"openid-client": "^5.6.0",
"pg": "^8.16.3"
}
}

58
templates/index.html Normal file
View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 | TutorOTP</title>
<style>
body {
font-family: sans-serif; padding: 1.5rem; font-size: 1.2rem;
}
a, button {
font-size: 1.1rem;
}
</style>
</head>
<body>
<h1>TutorOTP</h1>
<p>已登入為身分 {{USERNAME}}</p>
<form method="POST" action="/logout">
<button type="submit">登出</button>
</form>
<br>
<code>{{OTP}}</code>
<p>
<button class="setCounter" data-value="{{COUNTER}} - 1">--</button>
<span>{{COUNTER}}</span>
<button class="setCounter" data-value="{{COUNTER}} + 1">++</button>
</p>
<script>
document.querySelectorAll('.setCounter').forEach(btn => {
btn.addEventListener('click', async function () {
const value = eval(this.getAttribute('data-value'));
try {
const res = await fetch('/counter', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ counter: value })
});
if (!res.ok) {
alert('伺服器錯誤');
return;
}
const data = await res.json();
if (data && data.success) {
window.location.reload();
}
} catch (err) {
alert('請求失敗: ' + err);
}
});
});
</script>
</body>
</html>

22
templates/login-fail.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入失敗 | TutorOTP</title>
<style>
body {
font-family: sans-serif; padding: 1.5rem; font-size: 1.2rem;
}
a, button {
font-size: 1.1rem;
}
</style>
</head>
<body>
<h1>TutorOTP</h1>
<p>⚠️ 登入失敗</p>
<p>{{ERROR}}</p>
<a href="/">回首頁</a>
</body>
</html>

51
templates/login.html Normal file
View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登入 | TutorOTP</title>
<style>
body {
font-family: sans-serif;
padding: 1.5rem;
font-size: 1.2rem;
}
a,
button {
font-size: 1.1rem;
}
</style>
</head>
<body>
<h1>TutorOTP</h1>
<button id="loginBtn">登入</button>
<script>
document.getElementById('loginBtn').addEventListener('click', async function () {
const redirectUri = window.location.origin + '/callback';
try {
const res = await fetch('/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirectUri })
});
if (!res.ok) {
alert('伺服器錯誤');
return;
}
const data = await res.json();
if (data && data.redirect) {
window.location.href = data.redirect;
}
} catch (err) {
alert('請求失敗: ' + err);
}
});
</script>
</body>
</html>

30
utils/templates.js Normal file
View File

@@ -0,0 +1,30 @@
const fs = require("fs");
const path = require("path");
function createTemplateRenderer({
baseDir = path.resolve("templates"),
useCache = true,
} = {}) {
const cache = {};
return function render(fileName, vars = {}) {
const filePath = path.join(baseDir, fileName);
let template;
if (useCache && cache[filePath]) {
template = cache[filePath];
} else {
template = fs.readFileSync(filePath, "utf8");
if (useCache) cache[filePath] = template;
}
let html = template;
for (const [key, value] of Object.entries(vars)) {
html = html.replace(new RegExp(`{{${key}}}`, "g"), value);
}
return html;
};
}
module.exports = { createTemplateRenderer };