feat(auth): add user schema and auth api
This commit is contained in:
+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