Basic login

This commit is contained in:
2019-09-26 15:52:58 +02:00
parent f20d003e2a
commit e2bdaf6603
13 changed files with 514 additions and 12 deletions

55
src/crypto.ts Normal file
View File

@@ -0,0 +1,55 @@
// Copyright (C) 2019 ModZero <modzero@modzero.xyz>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { randomBytes, secretbox } from "tweetnacl";
import {
decodeBase64,
decodeUTF8,
encodeBase64,
encodeUTF8
} from "tweetnacl-util";
const secret = decodeBase64(process.env.SECRET);
const newNonce = () => randomBytes(secretbox.nonceLength);
const secretBoxKey = secret.slice(0, secretbox.keyLength);
if (secretBoxKey.length !== secretbox.keyLength) {
throw new Error("Secret too short to encrypt anything");
}
export const box = (json: any, key: Uint8Array = secretBoxKey) => {
const nonce = newNonce();
const messageUint8 = decodeUTF8(JSON.stringify(json));
const encrypted = secretbox(messageUint8, nonce, key);
const fullMessage = new Uint8Array(nonce.length + encrypted.length);
fullMessage.set(nonce);
fullMessage.set(encrypted, nonce.length);
return encodeBase64(fullMessage);
};
export const unbox = (
messageWithNonce: string,
key: Uint8Array = secretBoxKey
) => {
const fullMessageUint8 = decodeBase64(messageWithNonce);
const nonce = fullMessageUint8.slice(0, secretbox.nonceLength);
const message = fullMessageUint8.slice(
secretbox.nonceLength,
fullMessageUint8.length
);
const decrypted = secretbox.open(message, nonce, key);
return JSON.parse(encodeUTF8(decrypted));
};

View File

@@ -14,13 +14,14 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import pgPromise, { IDatabase, IInitOptions } from "pg-promise";
import { Extensions, MigrationRepository } from "./repos";
import { Extensions, MigrationRepository, UserRepository } from "./repos";
type ExtendedProtocol = IDatabase<Extensions> & Extensions;
const initOptions: IInitOptions<Extensions> = {
extend(obj: ExtendedProtocol, dc: any) {
obj.migrations = new MigrationRepository(obj, pgp);
obj.users = new UserRepository(obj, pgp);
}
};

View File

@@ -14,9 +14,11 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { MigrationRepository } from "./migrations";
import { UserRepository } from "./users";
export interface Extensions {
migrations: MigrationRepository;
users: UserRepository;
}
export { MigrationRepository };
export { MigrationRepository, UserRepository };

50
src/db/repos/users.ts Normal file
View File

@@ -0,0 +1,50 @@
// Copyright (C) 2019 ModZero <modzero@modzero.xyz>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import argon2 from "argon2";
import { Maybe, None, Some } from "monet";
import { IDatabase, IMain } from "pg-promise";
import { users as sql } from "../sql";
export class UserRepository {
private db: IDatabase<any>;
constructor(db: IDatabase<any>, pgp: IMain) {
this.db = db;
}
public async login(email: string, password: string): Promise<Maybe<number>> {
const { id, encryptedPassword } = await this.db
.oneOrNone(sql.login, [email])
.then(user => ({
encryptedPassword: user.encrypted_password,
id: +user.id
}));
if (id === null) {
return None();
}
return (await argon2.verify(encryptedPassword, password))
? Some(id)
: None();
}
public async create(email: string, password: string): Promise<number> {
const encryptedPassword = await argon2.hash(password);
return this.db
.one(sql.create, [email, encryptedPassword])
.then((user: { id: number }) => +user.id);
}
}

View File

@@ -33,7 +33,12 @@ const migrations = {
}))
};
export { migrations };
const users = {
create: sql("users/create.sql"),
login: sql("users/login.sql")
};
export { migrations, users };
/** Helper for linking to external query files */
function sql(file: string): QueryFile {

View File

@@ -0,0 +1 @@
INSERT INTO users (email, encrypted_password) VALUES ($1, $2) RETURNING id

View File

@@ -0,0 +1 @@
SELECT id, encrypted_password FROM users WHERE email=$1

View File

@@ -13,23 +13,41 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import cookieParser from "cookie-parser";
import express from "express";
import pinoExpress from "express-pino-logger";
import helmet from "helmet";
import createHttpError from "http-errors";
import { db } from "./db";
import { server as graphqlServer } from "./graphql";
import logger from "./logger";
import authRouter from "./routes/auth";
import indexRouter from "./routes/index";
async function main() {
await db.migrations.create();
await db.migrations.apply();
await db.tx(async t => {
await t.migrations.create();
await t.migrations.apply();
});
const server = graphqlServer();
const app = express();
const expressPino = pinoExpress({ logger });
app.use(helmet());
app.use(expressPino);
server.applyMiddleware({ app, path: "/graphql" });
const port = 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use("/", indexRouter);
app.use("/auth/", authRouter);
server.applyMiddleware({ app, path: "/graphql" });
app.use((req, res, next) => {
next(createHttpError(404));
});
const port = 3000;
app.listen(port, () =>
logger.info("Example app listening", {
uri: `http://localhost:${port}${server.graphqlPath}`

64
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,64 @@
// Copyright (C) 2019 ModZero <modzero@modzero.xyz>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import express from "express";
import createHttpError from "http-errors";
import { DateTime } from "luxon";
import { box, unbox } from "../crypto";
import { db } from "../db";
const router = express.Router();
router.post("/", async (req, res, next) => {
const userID = await db.users.login(req.body.email, req.body.password);
if (userID.isSome()) {
res.send(`Hi, ${userID.some()}`);
} else {
res.send(`Go away.`);
}
});
interface Token {
expires: string;
}
router.get("/bootstrap", async (req, res, next) => {
const token: Token = {
expires: DateTime.local()
.plus({ hours: 2 })
.toISO()
};
req.log.info("Token issued", { token: box(token) });
});
router.post("/bootstrap", async (req, res, next) => {
const token: Token = unbox(req.body.token);
const expired = DateTime.fromISO(token.expires).diffNow();
if (expired.as("milliseconds") < 0) {
next(createHttpError(401));
return;
}
const email: string = req.body.email;
const password: string = req.body.password;
if (!email || !password || password.length < 8) {
res.send("Please provide an email and a password longer than 8 characters");
return;
}
await db.users.create(email, password);
});
export default router;

24
src/routes/index.ts Normal file
View File

@@ -0,0 +1,24 @@
// Copyright (C) 2019 ModZero <modzero@modzero.xyz>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import express from "express";
const router = express.Router();
router.get("/", (req, res, next) => {
res.send("Hello, world!");
});
export default router;