first user is admin user, restrict signups

todo: actual admin interface (web and/or cli)
This commit is contained in:
2020-10-19 21:29:26 +03:00
committed by Stepan Usatiuk
parent 45444d0741
commit fc7d5b7f34
9 changed files with 255 additions and 16 deletions

View File

@@ -89,4 +89,4 @@
"pre-commit": "npm run lint-all && npm run prettier-check" "pre-commit": "npm run lint-all && npm run prettier-check"
} }
} }
} }

View File

@@ -69,14 +69,6 @@ const production: IConfig = {
const development: IConfig = { const development: IConfig = {
...production, ...production,
env: EnvType.development, env: EnvType.development,
dbConnectionOptions:
process.env.NODE_ENV === "development"
? fs.existsSync("./ormconfig.dev.json")
? (JSON.parse(
fs.readFileSync("./ormconfig.dev.json").toString(),
) as ConnectionOptions)
: null
: null,
}; };
const test: IConfig = { const test: IConfig = {

75
src/entity/Config.ts Normal file
View File

@@ -0,0 +1,75 @@
import { isIn, IsIn, validateOrReject } from "class-validator";
import {
BaseEntity,
BeforeInsert,
BeforeUpdate,
Column,
Entity,
PrimaryColumn,
} from "typeorm";
export enum ConfigKey {
"signupAllowed" = "signupAllowed",
}
export interface IDBConfig {
signupAllowed: boolean;
}
const defaultValues: Record<ConfigKey, string> = {
signupAllowed: "no",
};
@Entity()
export class Config extends BaseEntity {
@PrimaryColumn()
@IsIn(Object.values(ConfigKey))
public key: ConfigKey;
@Column()
public value: string;
@BeforeInsert()
async beforeInsertValidate(): Promise<void> {
return validateOrReject(this);
}
@BeforeUpdate()
async beforeUpdateValidate(): Promise<void> {
return validateOrReject(this);
}
constructor(key: ConfigKey, value: string) {
super();
this.key = key;
this.value = value;
}
}
export async function getConfigValue(key: ConfigKey): Promise<string> {
if (!isIn(key, Object.values(ConfigKey))) {
throw new Error(`${key} is not valid config key`);
}
try {
const pair = await Config.findOneOrFail({ key });
return pair.value;
} catch (e) {
return defaultValues[key];
}
}
export async function setConfigValue(
key: ConfigKey,
val: string,
): Promise<void> {
if (!isIn(key, Object.values(ConfigKey))) {
throw new Error(`${key} is not valid config key`);
}
let pair = await Config.findOne({ key });
if (!pair) {
pair = new Config(key, val);
}
await pair.save();
}

View File

@@ -90,7 +90,10 @@ export class Photo extends BaseEntity {
@Column({ type: "timestamp", default: null }) @Column({ type: "timestamp", default: null })
public editedAt: Date; public editedAt: Date;
@ManyToOne(() => User, (user) => user.photos, { eager: true }) @ManyToOne(() => User, (user) => user.photos, {
eager: true,
onDelete: "CASCADE",
})
public user: User; public user: User;
public getFileName(): string { public getFileName(): string {

View File

@@ -26,7 +26,7 @@ import {
validateOrReject, validateOrReject,
} from "class-validator"; } from "class-validator";
export type IUserJSON = Pick<User, "id" | "username">; export type IUserJSON = Pick<User, "id" | "username" | "isAdmin">;
export interface IUserJWT extends IUserJSON { export interface IUserJWT extends IUserJSON {
ext: number; ext: number;
@@ -58,6 +58,9 @@ export class User extends BaseEntity {
@OneToMany(() => Photo, (photo) => photo.user) @OneToMany(() => Photo, (photo) => photo.user)
photos: Promise<Photo[]>; photos: Promise<Photo[]>;
@Column({ default: false })
public isAdmin: boolean;
constructor(username: string, email: string) { constructor(username: string, email: string) {
super(); super();
this.username = username; this.username = username;
@@ -97,13 +100,13 @@ export class User extends BaseEntity {
} }
public toJSON(): IUserJSON { public toJSON(): IUserJSON {
const { id, username } = this; const { id, username, isAdmin } = this;
return { id, username }; return { id, username, isAdmin };
} }
public toAuthJSON(): IUserAuthJSON { public toAuthJSON(): IUserAuthJSON {
const { id, username } = this; const json = this.toJSON();
return { id, username, jwt: this.toJWT() }; return { ...json, jwt: this.toJWT() };
} }
public toJWT(): string { public toJWT(): string {

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class restrictSignups1603132068670 implements MigrationInterface {
name = "restrictSignups1603132068670";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `photo` DROP FOREIGN KEY `FK_4494006ff358f754d07df5ccc87`",
);
await queryRunner.query(
"CREATE TABLE `config` (`key` varchar(255) NOT NULL, `value` varchar(255) NOT NULL, PRIMARY KEY (`key`)) ENGINE=InnoDB",
);
await queryRunner.query(
"ALTER TABLE `user` ADD `isAdmin` tinyint NOT NULL DEFAULT 0",
);
await queryRunner.query(
"ALTER TABLE `photo` ADD CONSTRAINT `FK_4494006ff358f754d07df5ccc87` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE `photo` DROP FOREIGN KEY `FK_4494006ff358f754d07df5ccc87`",
);
await queryRunner.query("ALTER TABLE `user` DROP COLUMN `isAdmin`");
await queryRunner.query("DROP TABLE `config`");
await queryRunner.query(
"ALTER TABLE `photo` ADD CONSTRAINT `FK_4494006ff358f754d07df5ccc87` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION",
);
}
}

View File

@@ -1,4 +1,5 @@
import * as Router from "@koa/router"; import * as Router from "@koa/router";
import { getConfigValue, ConfigKey } from "~entity/Config";
import { IUserAuthJSON, IUserJWT, User } from "~entity/User"; import { IUserAuthJSON, IUserJWT, User } from "~entity/User";
import { IAPIResponse } from "~types"; import { IAPIResponse } from "~types";
@@ -70,6 +71,17 @@ userRouter.post("/users/signup", async (ctx) => {
} }
const user = new User(username, email); const user = new User(username, email);
const users = await User.find();
if (users.length === 0) {
user.isAdmin = true;
}
if ((await getConfigValue(ConfigKey.signupAllowed)) !== "yes") {
if (users.length !== 0) {
ctx.throw(400, "Signups not allowed");
}
}
await user.setPassword(password); await user.setPassword(password);
try { try {

View File

@@ -14,7 +14,7 @@ import {
IUserSignupRespBody, IUserSignupRespBody,
} from "~routes/users"; } from "~routes/users";
import { ISeed, seedDB } from "./util"; import { allowSignups, ISeed, seedDB } from "./util";
const callback = app.callback(); const callback = app.callback();
@@ -87,6 +87,8 @@ describe("users", function () {
}); });
it("should signup user", async function () { it("should signup user", async function () {
await allowSignups();
const response = await request(callback) const response = await request(callback)
.post("/users/signup") .post("/users/signup")
.set({ "Content-Type": "application/json" }) .set({ "Content-Type": "application/json" })
@@ -110,7 +112,121 @@ describe("users", function () {
expect(user).to.deep.equal(newUser.toJSON()); expect(user).to.deep.equal(newUser.toJSON());
}); });
it("should not signup user if other exist (by default)", async function () {
const response = await request(callback)
.post("/users/signup")
.set({ "Content-Type": "application/json" })
.send({
username: "NUser1",
password: "NUser1",
email: "nuser1@users.com",
} as IUserSignupBody)
.expect("Content-Type", /json/)
.expect(400);
const body = response.body as IUserSignupRespBody;
expect(body.error).to.be.equal("Signups not allowed");
expect(body.data).to.be.false;
});
it("should signup first user and it should be admin, do not signup new users (by default)", async function () {
await User.remove(await User.find());
const response = await request(callback)
.post("/users/signup")
.set({ "Content-Type": "application/json" })
.send({
username: "NUser1",
password: "NUser1",
email: "nuser1@users.com",
} as IUserSignupBody)
.expect("Content-Type", /json/)
.expect(200);
const body = response.body as IUserSignupRespBody;
if (body.error !== false) {
assert(false);
return;
}
const { jwt: _, ...user } = body.data;
const newUser = await User.findOneOrFail({ username: "NUser1" });
expect(user).to.deep.equal(newUser.toJSON());
expect(user.isAdmin).to.be.true;
const response2 = await request(callback)
.post("/users/signup")
.set({ "Content-Type": "application/json" })
.send({
username: "NUser2",
password: "NUser2",
email: "nuser2@users.com",
} as IUserSignupBody)
.expect("Content-Type", /json/)
.expect(400);
const body2 = response2.body as IUserSignupRespBody;
expect(body2.error).to.be.equal("Signups not allowed");
expect(body2.data).to.be.false;
});
it("should signup first user and it should be admin, but not new ones", async function () {
await allowSignups();
await User.remove(await User.find());
const response = await request(callback)
.post("/users/signup")
.set({ "Content-Type": "application/json" })
.send({
username: "NUser1",
password: "NUser1",
email: "nuser1@users.com",
} as IUserSignupBody)
.expect("Content-Type", /json/)
.expect(200);
const body = response.body as IUserSignupRespBody;
if (body.error !== false) {
assert(false);
return;
}
const { jwt: jwt1, ...user } = body.data;
const newUser = await User.findOneOrFail({ username: "NUser1" });
expect(user).to.deep.equal(newUser.toJSON());
expect(user.isAdmin).to.be.true;
const response2 = await request(callback)
.post("/users/signup")
.set({ "Content-Type": "application/json" })
.send({
username: "NUser2",
password: "NUser2",
email: "nuser2@users.com",
} as IUserSignupBody)
.expect("Content-Type", /json/)
.expect(200);
const body2 = response2.body as IUserSignupRespBody;
if (body2.error !== false) {
assert(false);
return;
}
const { jwt: jwt2, ...user2 } = body2.data;
const newUser2 = await User.findOneOrFail({ username: "NUser2" });
expect(user2).to.deep.equal(newUser2.toJSON());
expect(user2.isAdmin).to.be.false;
});
it("should not signup user with duplicate username", async function () { it("should not signup user with duplicate username", async function () {
await allowSignups();
const response = await request(callback) const response = await request(callback)
.post("/users/signup") .post("/users/signup")
.set({ "Content-Type": "application/json" }) .set({ "Content-Type": "application/json" })

View File

@@ -3,6 +3,8 @@ import * as fs from "fs/promises";
import { User } from "entity/User"; import { User } from "entity/User";
import { Photo } from "~entity/Photo"; import { Photo } from "~entity/Photo";
import { getHash, getSize } from "~util"; import { getHash, getSize } from "~util";
import { Config, ConfigKey, setConfigValue } from "~entity/Config";
import { config } from "chai";
export const dogPath = "./src/tests/integration/photos/dog.jpg"; export const dogPath = "./src/tests/integration/photos/dog.jpg";
export const catPath = "./src/tests/integration/photos/cat.jpg"; export const catPath = "./src/tests/integration/photos/cat.jpg";
@@ -48,6 +50,7 @@ export async function seedDB(): Promise<ISeed> {
await Photo.remove(await Photo.find()); await Photo.remove(await Photo.find());
await User.remove(await User.find()); await User.remove(await User.find());
await Config.remove(await Config.find());
const user1 = new User("User1", "user1@users.com"); const user1 = new User("User1", "user1@users.com");
await user1.setPassword("User1"); await user1.setPassword("User1");
@@ -68,3 +71,7 @@ export async function seedDB(): Promise<ISeed> {
return { user1, user2, dogPhoto, catPhoto }; return { user1, user2, dogPhoto, catPhoto };
} }
export async function allowSignups(): Promise<void> {
await setConfigValue(ConfigKey.signupAllowed, "yes");
}