feat(auth): add user schema and auth api

This commit is contained in:
2026-05-20 22:26:23 +07:00
commit 9d7114e2f1
13 changed files with 1658 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
PORT=3000
NODE_ENV=development
MONGODB_URI=mongodb://localhost:27017/noctune
JWT_SECRET=isi_random_string_panjang
JWT_REFRESH_SECRET=isi_random_string_lain
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CLIENT_URL=http://localhost:5173
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
uploads/
*.log
.DS_Store
+29
View File
@@ -0,0 +1,29 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.6.0",
"express": "^4.21.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.13.0",
"morgan": "^1.10.0"
},
"devDependencies": {
"nodemon": "^3.1.2"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.30.3"
}
+1156
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import morgan from 'morgan';
import routes from './routes/index.js';
const app = express();
app.use(
cors({
origin: process.env.CLIENT_URL || 'http://localhost:5173',
credentials: true,
})
);
app.use(helmet());
app.use(express.json({ limit: '2mb' }));
app.use(cookieParser());
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.use('/api', routes);
app.use((req, res) => {
res.status(404).json({ success: false, message: 'Route not found' });
});
app.use((err, req, res, next) => {
const status = err.status || 500;
res
.status(status)
.json({ success: false, message: err.message || 'Server error' });
});
export default app;
+11
View File
@@ -0,0 +1,11 @@
import mongoose from 'mongoose';
export const connectDb = async () => {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error('MONGODB_URI is not set');
}
mongoose.set('strictQuery', true);
await mongoose.connect(uri);
};
+95
View File
@@ -0,0 +1,95 @@
import {
getSessionUser,
loginUser,
refreshSession,
registerUser,
} from '../services/authService.js';
const accessCookieName = 'noctune_access';
const refreshCookieName = 'noctune_refresh';
const getCookieOptions = () => ({
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
const setAuthCookies = (res, tokens) => {
const options = getCookieOptions();
res.cookie(accessCookieName, tokens.accessToken, {
...options,
maxAge: tokens.accessMaxAge,
});
res.cookie(refreshCookieName, tokens.refreshToken, {
...options,
maxAge: tokens.refreshMaxAge,
});
};
const clearAuthCookies = (res) => {
const options = getCookieOptions();
res.clearCookie(accessCookieName, options);
res.clearCookie(refreshCookieName, options);
};
export const authController = {
async register(req, res, next) {
try {
const { name, username, email, password } = req.body;
const { user, tokens } = await registerUser({
name,
username,
email,
password,
});
setAuthCookies(res, tokens);
res.json({ success: true, data: { user } });
} catch (error) {
next(error);
}
},
async login(req, res, next) {
try {
const { identifier, password } = req.body;
const { user, tokens } = await loginUser({ identifier, password });
setAuthCookies(res, tokens);
res.json({ success: true, data: { user } });
} catch (error) {
next(error);
}
},
async refresh(req, res, next) {
try {
const refreshToken = req.cookies?.[refreshCookieName];
const { user, tokens } = await refreshSession(refreshToken);
setAuthCookies(res, tokens);
res.json({ success: true, data: { user } });
} catch (error) {
next(error);
}
},
async me(req, res, next) {
try {
const user = await getSessionUser(req.userId);
res.json({ success: true, data: { user } });
} catch (error) {
next(error);
}
},
async logout(req, res) {
clearAuthCookies(res);
res.json({ success: true, data: { message: 'Logged out' } });
},
};
+54
View File
@@ -0,0 +1,54 @@
import jwt from 'jsonwebtoken';
const accessCookieName = 'noctune_access';
const getToken = (req) => req.cookies?.[accessCookieName];
export const auth = (req, res, next) => {
const token = getToken(req);
if (!token) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const userId =
typeof decoded === 'object' && decoded !== null && 'sub' in decoded
? decoded.sub
: null;
if (!userId) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
req.userId = userId;
return next();
} catch (error) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
};
export const optionalAuth = (req, res, next) => {
const token = getToken(req);
if (!token) {
return next();
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const userId =
typeof decoded === 'object' && decoded !== null && 'sub' in decoded
? decoded.sub
: null;
if (userId) {
req.userId = userId;
}
return next();
} catch (error) {
return next();
}
};
+46
View File
@@ -0,0 +1,46 @@
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema(
{
name: {
type: String,
trim: true,
maxlength: 80,
},
username: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
minlength: 3,
maxlength: 30,
},
email: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
},
passwordHash: {
type: String,
required: true,
select: false,
},
role: {
type: String,
enum: ['user', 'artist', 'admin'],
default: 'user',
},
avatarUrl: {
type: String,
trim: true,
},
},
{
timestamps: true,
}
);
export default mongoose.model('User', userSchema);
+13
View File
@@ -0,0 +1,13 @@
import { Router } from 'express';
import { authController } from '../controllers/authController.js';
import { auth } from '../middlewares/auth.js';
const router = Router();
router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);
router.get('/me', auth, authController.me);
export default router;
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express';
import authRoutes from './auths.js';
const router = Router();
router.get('/health', (req, res) => {
res.json({
success: true,
data: {
status: 'ok',
time: new Date().toISOString(),
},
});
});
router.use('/auth', authRoutes);
export default router;
+20
View File
@@ -0,0 +1,20 @@
import 'dotenv/config';
import app from './app.js';
import { connectDb } from './config/db.js';
const port = Number(process.env.PORT) || 3000;
const startServer = async () => {
try {
await connectDb();
app.listen(port, () => {
process.stdout.write(`Server listening on port ${port}\n`);
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
process.stderr.write(`Failed to start server: ${message}\n`);
process.exit(1);
}
};
startServer();
+170
View File
@@ -0,0 +1,170 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import User from '../models/User.js';
const accessTokenTtl = process.env.JWT_EXPIRES_IN || '15m';
const refreshTokenTtl = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
const createError = (status, message) => {
const error = new Error(message);
error.status = status;
return error;
};
const durationToMs = (value, fallbackMs) => {
if (!value) {
return fallbackMs;
}
const match = /^([0-9]+)([smhd])$/.exec(value);
if (!match) {
return fallbackMs;
}
const amount = Number(match[1]);
const unit = match[2];
const multipliers = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return amount * (multipliers[unit] || 1000);
};
const ensureSecrets = () => {
if (!process.env.JWT_SECRET || !process.env.JWT_REFRESH_SECRET) {
throw createError(500, 'JWT secrets are not configured');
}
};
const normalize = (value) => value.trim().toLowerCase();
const buildTokens = (userId) => {
ensureSecrets();
const accessToken = jwt.sign({ sub: userId }, process.env.JWT_SECRET, {
expiresIn: accessTokenTtl,
});
const refreshToken = jwt.sign(
{ sub: userId },
process.env.JWT_REFRESH_SECRET,
{
expiresIn: refreshTokenTtl,
}
);
return {
accessToken,
refreshToken,
accessMaxAge: durationToMs(accessTokenTtl, 15 * 60 * 1000),
refreshMaxAge: durationToMs(refreshTokenTtl, 7 * 24 * 60 * 60 * 1000),
};
};
const getPublicUser = async (userId) => {
if (!userId) {
return null;
}
return User.findById(userId).select('-passwordHash').lean();
};
export const registerUser = async ({ name, username, email, password }) => {
if (!username || !email || !password) {
throw createError(400, 'Username, email, and password are required');
}
const normalizedUsername = normalize(username);
const normalizedEmail = normalize(email);
const existing = await User.findOne({
$or: [{ username: normalizedUsername }, { email: normalizedEmail }],
}).lean();
if (existing) {
throw createError(409, 'Username or email already in use');
}
const passwordHash = await bcrypt.hash(password, 10);
const createdUser = await User.create({
name: name?.trim() || normalizedUsername,
username: normalizedUsername,
email: normalizedEmail,
passwordHash,
});
const user = await getPublicUser(createdUser._id);
return {
user,
tokens: buildTokens(createdUser._id.toString()),
};
};
export const loginUser = async ({ identifier, password }) => {
if (!identifier || !password) {
throw createError(400, 'Identifier and password are required');
}
const normalizedIdentifier = normalize(identifier);
const user = await User.findOne({
$or: [{ email: normalizedIdentifier }, { username: normalizedIdentifier }],
}).select('+passwordHash');
if (!user) {
throw createError(401, 'Invalid credentials');
}
const matches = await bcrypt.compare(password, user.passwordHash);
if (!matches) {
throw createError(401, 'Invalid credentials');
}
const publicUser = await getPublicUser(user._id);
return {
user: publicUser,
tokens: buildTokens(user._id.toString()),
};
};
export const refreshSession = async (refreshToken) => {
if (!refreshToken) {
throw createError(401, 'Refresh token missing');
}
ensureSecrets();
let decoded;
try {
decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
} catch (error) {
throw createError(401, 'Invalid refresh token');
}
const userId =
typeof decoded === 'object' && decoded !== null && 'sub' in decoded
? decoded.sub
: null;
const user = await getPublicUser(userId);
if (!user) {
throw createError(401, 'User not found');
}
return {
user,
tokens: buildTokens(userId.toString()),
};
};
export const getSessionUser = async (userId) => {
const user = await getPublicUser(userId);
if (!user) {
throw createError(401, 'User not found');
}
return user;
};