feat(auth): add user schema and auth api
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
uploads/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Generated
+1156
File diff suppressed because it is too large
Load Diff
+33
@@ -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;
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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' } });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user