Implement simple account register and login

This commit is contained in:
Hakan Shehu
2024-07-29 15:15:32 +02:00
parent 76994d2ef9
commit 752f2c07b1
11 changed files with 1617 additions and 11 deletions

1273
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,20 +5,31 @@
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon --watch src --exec ts-node src/index.ts"
"dev": "node -r ts-node/register -r tsconfig-paths/register --env-file .env src/index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^22.0.0",
"concurrently": "^8.2.2",
"nodemon": "^3.1.4",
"prisma": "^5.17.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
},
"dependencies": {
"express": "^4.19.2"
"@prisma/client": "^5.17.0",
"axios": "^1.7.2",
"bcrypt": "^5.1.1",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"postgres": "^3.4.4",
"ulid": "^2.3.0"
}
}

View File

@@ -0,0 +1,20 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model accounts {
id String @id @db.VarChar(30)
name String @db.VarChar(256)
email String @unique(map: "IX_accounts_email") @db.VarChar(256)
avatar String? @db.VarChar(256)
password String? @db.VarChar(256)
attrs Json? @default("null")
createdAt DateTime @db.Timestamptz(6) @map("created_at")
updatedAt DateTime? @db.Timestamptz(6) @map("updated_at")
status Int
}

3
server/src/data/db.ts Normal file
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()

View File

@@ -1,12 +1,20 @@
import express, { Request, Response } from 'express';
import {accounts} from "./routes/accounts";
const app = express();
const port = 3000;
app.use(express.json());
app.get('/', (req: Request, res: Response) => {
res.send('Hello World!');
res.send('Neuron');
});
//accounts
app.post('/v1/accounts/login/google', accounts.loginWithGoogle);
app.post('/v1/accounts/login/email', accounts.loginWithEmail);
app.post('/v1/accounts/register/email', accounts.registerWithEmail);
app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});

11
server/src/lib/id.ts Normal file
View File

@@ -0,0 +1,11 @@
import { monotonicFactory } from 'ulid'
const ulid = monotonicFactory()
export function generateId(type: IdType) {
return ulid().toLowerCase() + type
}
export enum IdType {
Account = 'ac',
}

View File

@@ -0,0 +1,157 @@
import {Request, Response} from "express";
import {
Account,
AccountStatus, EmailLoginInput,
EmailRegisterInput,
GoogleLoginInput,
GoogleUserInfo,
LoginOutput
} from "@/types/accounts";
import axios from "axios";
import {ApiError} from "@/types/api";
import {createAccount, findAccountByEmail, hashPassword, updateGoogleId, verifyPassword} from "@/services/accounts";
import {generateId, IdType} from "@/lib/id";
import jwt from "jsonwebtoken";
const GoogleUserInfoUrl = "https://www.googleapis.com/oauth2/v1/userinfo";
const JwtSecretKey = process.env.JWT_SECRET ?? '';
const JwtAudience = process.env.JWT_AUDIENCE ?? '';
const JwtIssuer = process.env.JWT_ISSUER ?? '';
function buildLoginOutput(account: Account): LoginOutput {
const signOptions: jwt.SignOptions = {
issuer: JwtIssuer,
audience: JwtAudience,
subject: account.id,
expiresIn: '1y',
};
const payload = {
name: account.name,
email: account.email,
};
const token = jwt.sign(payload, JwtSecretKey, signOptions);
return {
token
};
}
async function registerWithEmail(req: Request, res: Response) {
const input: EmailRegisterInput = req.body;
let existingUser = await findAccountByEmail(input.email);
if (existingUser) {
return res.status(400).json({
code: ApiError.EmailAlreadyExists,
message: "Email already exists."
});
}
const password = await hashPassword(input.password);
const account: Account = {
id: generateId(IdType.Account),
name: input.name,
email: input.email,
password: password,
status: AccountStatus.Active,
createdAt: new Date(),
};
await createAccount(account);
return res.json(buildLoginOutput(account));
}
async function loginWithEmail(req: Request, res: Response) {
const input: EmailLoginInput = req.body;
let existingUser = await findAccountByEmail(input.email);
if (!existingUser) {
return res.status(400).json({
code: ApiError.EmailOrPasswordIncorrect,
message: "Invalid credentials."
});
}
if (existingUser.status === AccountStatus.Pending) {
return res.status(400).json({
code: ApiError.UserPendingActivation,
message: "User is pending activation."
});
}
if (!existingUser.password) {
return res.status(400).json({
code: ApiError.EmailOrPasswordIncorrect,
message: "Invalid credentials."
});
}
let passwordMatch = await verifyPassword(input.password, existingUser.password);
if (!passwordMatch) {
return res.status(400).json({
code: ApiError.EmailOrPasswordIncorrect,
message: "Invalid credentials."
});
}
return res.json(buildLoginOutput(existingUser));
}
async function loginWithGoogle(req: Request, res: Response) {
const input: GoogleLoginInput = req.body;
const url = `${GoogleUserInfoUrl}?access_token=${input.access_token}`;
const userInfoResponse = await axios.get(url);
if (userInfoResponse.status !== 200) {
return res.status(400).json({
code: ApiError.GoogleAuthFailed,
message: "Failed to authenticate with Google."
});
}
const googleUser: GoogleUserInfo = userInfoResponse.data;
if (!googleUser) {
return res.status(400).json({
code: ApiError.GoogleAuthFailed,
message: "Failed to authenticate with Google."
});
}
let existingUser = await findAccountByEmail(googleUser.email);
if (existingUser) {
if (existingUser.status === AccountStatus.Pending) {
return res.status(400).json({
code: ApiError.UserPendingActivation,
message: "User is pending activation."
});
}
await updateGoogleId(existingUser.id, googleUser.id);
return res.json(buildLoginOutput(existingUser));
}
const account: Account = {
id: generateId(IdType.Account),
name: googleUser.name,
email: googleUser.email,
status: AccountStatus.Active,
createdAt: new Date(),
attrs: {
googleId: googleUser.id
}
};
await createAccount(account);
return res.json(buildLoginOutput(account));
}
export const accounts = {
loginWithGoogle,
loginWithEmail,
registerWithEmail
}

View File

@@ -0,0 +1,80 @@
import {prisma} from "@/data/db";
import {Account} from "@/types/accounts";
import bcrypt from 'bcrypt';
const SaltRounds = 10;
export async function findAccountByEmail(email: string): Promise<Account | null> {
const account = await prisma
.accounts
.findUnique({
where: {
email
}
});
if (!account) {
return null;
}
return {
id: account.id,
name: account.name,
email: account.email,
password: account.password,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
status: account.status,
attrs: account.attrs as Record<string, any>,
};
}
export async function updateGoogleId(accountId: string, googleId: string): Promise<void> {
await prisma
.accounts
.update({
where: {
id: accountId
},
data: {
attrs: { googleId },
updatedAt: new Date()
}
});
}
export async function createAccount(account: Account): Promise<Account> {
const newAccount = await prisma
.accounts
.create({
data: {
id: account.id,
name: account.name,
email: account.email,
password: account.password,
createdAt: account.createdAt,
status: account.status,
attrs: account.attrs
}
});
return {
id: newAccount.id,
name: newAccount.name,
email: newAccount.email,
password: newAccount.password,
createdAt: newAccount.createdAt,
updatedAt: newAccount.updatedAt,
status: newAccount.status,
attrs: newAccount.attrs as Record<string, any>,
};
}
export async function hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(SaltRounds);
return await bcrypt.hash(password, salt);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return await bcrypt.compare(password, hash);
}

View File

@@ -0,0 +1,44 @@
export type GoogleLoginInput = {
access_token: string;
token_type: string;
expires_in: number;
};
export type EmailRegisterInput = {
name: string;
email: string;
password: string;
};
export type EmailLoginInput = {
email: string;
password: string;
};
export type GoogleUserInfo = {
id: string;
email: string;
name: string;
picture: string;
};
export type LoginOutput = {
token: string;
};
export enum AccountStatus {
Pending = 1,
Active = 2,
}
export type Account = {
id: string;
name: string;
email: string;
avatar?: string | null;
password?: string | null;
attrs?: Record<string, any>;
createdAt: Date;
updatedAt?: Date | null;
status: AccountStatus;
};

7
server/src/types/api.ts Normal file
View File

@@ -0,0 +1,7 @@
export enum ApiError {
GoogleAuthFailed = "GoogleAuthFailed",
UserPendingActivation = "UserPendingActivation",
InternalServerError = "InternalServerError",
EmailAlreadyExists = "EmailAlreadyExists",
EmailOrPasswordIncorrect = "EmailOrPasswordIncorrect",
}

View File

@@ -3,8 +3,12 @@
"target": "ES6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"rootDir": ".",
"strict": true,
"esModuleInterop": true
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
}
}