mirror of
https://github.com/colanode/colanode.git
synced 2025-12-16 11:47:47 +01:00
Implement simple account register and login
This commit is contained in:
1273
server/package-lock.json
generated
1273
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
20
server/prisma/schema.prisma
Normal file
20
server/prisma/schema.prisma
Normal 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
3
server/src/data/db.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
export const prisma = new PrismaClient()
|
||||
@@ -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
11
server/src/lib/id.ts
Normal 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',
|
||||
}
|
||||
157
server/src/routes/accounts.ts
Normal file
157
server/src/routes/accounts.ts
Normal 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
|
||||
}
|
||||
80
server/src/services/accounts.ts
Normal file
80
server/src/services/accounts.ts
Normal 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);
|
||||
}
|
||||
44
server/src/types/accounts.ts
Normal file
44
server/src/types/accounts.ts
Normal 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
7
server/src/types/api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum ApiError {
|
||||
GoogleAuthFailed = "GoogleAuthFailed",
|
||||
UserPendingActivation = "UserPendingActivation",
|
||||
InternalServerError = "InternalServerError",
|
||||
EmailAlreadyExists = "EmailAlreadyExists",
|
||||
EmailOrPasswordIncorrect = "EmailOrPasswordIncorrect",
|
||||
}
|
||||
@@ -3,8 +3,12 @@
|
||||
"target": "ES6",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user