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": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
"nodemon": "^3.1.4",
|
"nodemon": "^3.1.4",
|
||||||
|
"prisma": "^5.17.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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 express, { Request, Response } from 'express';
|
||||||
|
import {accounts} from "./routes/accounts";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
app.get('/', (req: Request, res: Response) => {
|
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, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server is running at http://localhost:${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",
|
"target": "ES6",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": ".",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true
|
"esModuleInterop": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user