add login with sessian and cleanup
All checks were successful
Build and Push Docker Images / build (push) Successful in 2m34s

This commit is contained in:
Rene Kievits
2025-10-22 02:07:56 +02:00
parent 673d29b05f
commit 1980e14e88
31 changed files with 830 additions and 68 deletions

View File

@@ -0,0 +1,117 @@
import express, { type Router, type Request, type Response } from 'express'
import jwt from 'jsonwebtoken'
import { UserModel } from '../../../models/user.model.ts'
import ldapAuth from './ldap.ts'
const router = express.Router()
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!
function createAccessToken(user: any) {
return jwt.sign(
{ sub: user._id, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' },
)
}
function createRefreshToken(user: any) {
return jwt.sign(
{ sub: user._id },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' },
)
}
router.post('/login', async (req: Request, res: Response) => {
const { email, username, password } = req.body
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' })
try {
const ldapUser = await ldapAuth({ username, password })
if (!ldapUser.auth) return res.status(401).json({ error: 'Invalid credentials' })
let user = await UserModel.findOne({ username: ldapUser.user.cn })
if (!user) {
user = await UserModel.create({
username: ldapUser.user.cn,
email: ldapUser.user.dn,
refresh_token: '',
})
}
const accessToken = createAccessToken(user)
const refreshToken = createRefreshToken(user)
user.refreshToken = refreshToken
await user.save()
res.cookie('access_token', accessToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 15 * 60 * 1000,
})
res.cookie('refresh_token', refreshToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 3600 * 1000,
})
res.json({
ok: true,
user: {
username: ldapUser.user.cn,
email: ldapUser.user.dn
},
})
} catch (err) {
console.error(err)
res.status(401).json({ error: 'Invalid credentials' })
}
})
router.post('/refresh', async (req: Request, res: Response) => {
const token = req.cookies.refresh_token
if (!token) return res.status(401).json({ error: 'No refresh token' })
try {
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
const user = await UserModel.findById(payload.sub)
if (!user || !user.refreshToken === token) return res.status(403).json({ error: 'Invalid refresh token' })
const newAccessToken = createAccessToken(user)
const newRefreshToken = createRefreshToken(user)
user.refreshToken = newRefreshToken
await user.save()
res.cookie('access_token', newAccessToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 15 * 60 * 1000,
})
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 3600 * 1000,
})
res.json({ ok: true })
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' })
}
})
router.post('/logout', async (req: Request, res: Response) => {
const token = req.cookies.refresh_token
if (token) {
try {
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
const user = await UserModel.findById(payload.sub)
if (user) {
user.refreshToken = ''
await user.save()
}
} catch { }
}
res.clearCookie('access_token')
res.clearCookie('refresh_token')
res.json({ loggedOut: true })
})
export default router as Router

View File

@@ -0,0 +1,31 @@
import { Client } from 'ldapts'
const LDAP_URL = 'ldap://192.168.0.26:389';
const BASE_DN = 'DC=ldap,DC=goauthentik,DC=io';
async function ldapAuth(userOptions: any) {
const { username, password } = userOptions;
if (!username || !password) return { auth: false }
const client = new Client({ url: LDAP_URL });
try {
const userDN = `cn=${username},ou=users,${BASE_DN}`;
await client.bind(userDN, password);
const { searchEntries } = await client.search(BASE_DN, {
scope: 'sub',
filter: `(cn=${username})`,
attributes: ['cn', 'mail'],
});
return { auth: true, user: searchEntries[0] }
} catch (err) {
return { auth: false }
} finally {
await client.unbind().catch(() => { })
}
}
export default ldapAuth

View File

@@ -1,12 +1,15 @@
import express, { type Router, type Request, type Response } from 'express'
import express, { type Router, type Response } from 'express'
import { ApiKeyModel } from '../../../models/apikey.model.ts'
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
const router = express.Router()
router.use(verifyAccessToken)
router.get('/', async (_req: Request, res: Response) => {
router.get('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
try {
const doc = await ApiKeyModel.findOne({})
const userId = req.userId
const doc = await ApiKeyModel.findOne({ userId: userId })
const apiKey = doc?.apiKey || ''
@@ -17,17 +20,19 @@ router.get('/', async (_req: Request, res: Response) => {
}
})
router.post('/', async (req: Request, res: Response) => {
router.post('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
try {
const { apiKey } = req.body as { apiKey?: string }
const { apiKey } = req.body
if (!apiKey || !apiKey.trim()) {
return res.status(400).json({ error: 'Invalid API key' })
}
console.log(req.body)
const userId = req.userId
await ApiKeyModel.updateOne(
{},
{ $set: { apiKey: apiKey } },
{ upsert: true }
{ $set: { userId: userId, apiKey: apiKey, lastUsed: new Date() } },
{ upsert: true },
)
res.json({ success: true })
@@ -37,9 +42,10 @@ router.post('/', async (req: Request, res: Response) => {
}
})
router.delete('/', async (_req: Request, res: Response) => {
router.delete('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
try {
const result = await ApiKeyModel.deleteOne({})
const userId = req.userId
const result = await ApiKeyModel.deleteOne({ userId: userId })
if (result.deletedCount === 0) {
console.log('No API key found to delete.')

View File

@@ -7,7 +7,6 @@ const router = express.Router()
router.get('/', async (_req: Request, res: Response) => {
try {
const doc = await KanjiModel.find()
console.log(doc)
res.json(doc)
} catch (error) {
console.error('Error fetching Kanji Subjects', error)

View File

@@ -0,0 +1,22 @@
import express, { type Router } from 'express'
import { UserModel } from '../../../models/user.model.ts'
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
const router = express.Router()
router.get('/info', verifyAccessToken, async (req: AuthRequest, res) => {
try {
if (!req.userId) return res.status(401).json({ ok: false, message: 'Unauthorized' })
const user = await UserModel.findById(req.userId).select('-refreshToken -__v -createdAt -updatedAt')
if (!user) return res.status(404).json({ ok: false, message: 'User not found' })
return res.json({ ok: true, user })
} catch (err) {
console.error(err)
return res.status(500).json({ ok: false, message: 'Server error' })
}
})
export default router as Router

View File

@@ -1,26 +1,22 @@
import express, { Router } from 'express'
import express, { type Router, type Response } from 'express'
import { ApiKeyModel } from '../../../models/apikey.model.ts'
import { syncWanikaniData } from '../../../services/wanikaniService.ts'
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
const router = express.Router()
interface ApiKeyDocument {
apiKey?: string;
}
router.get('/sync', async (req, res) => {
router.get('/sync', verifyAccessToken, async (req: AuthRequest, res: Response) => {
if (!req.userId) return res.status(401).json({ error: 'Unauthorized' })
try {
const apiKeyDoc = await ApiKeyModel.findOne() as ApiKeyDocument | null
const apiKeyDoc = await ApiKeyModel.findOne({ userId: req.userId })
const apiKey = apiKeyDoc?.apiKey
console.log(apiKey, apiKeyDoc)
if (!apiKey || apiKey.trim() === '') {
return res.status(401).json({ error: 'API Key not configured. Please sync your key first.' })
}
await syncWanikaniData(apiKey)
await syncWanikaniData(apiKey, req.userId)
res.json({ success: true })
} catch (err) {
console.error(err)

View File

@@ -1,7 +1,7 @@
import mongoose from 'mongoose'
export async function connectMongo() {
const url = process.env.NODE_ENV === 'DEV' ? 'mongodb://mongo-srs:27017/srs' : 'mongodb://192.168.0.26:27017/srs'
const url = process.env.NODE_ENV === 'DEV' ? 'mongodb://mongo:27017/srs' : 'mongodb://192.168.0.26:27017/srs'
if (mongoose.connection.readyState === 1) return
await mongoose.connect(url)
console.log('✅ Connected to MongoDB at', url)

View File

@@ -1,19 +1,68 @@
import dotenv from 'dotenv'
dotenv.config()
import express from 'express'
import { connectMongo } from './db/connect.ts'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import { connectMongo } from './db/connect.ts'
import keyRouter from './api/v1/key/index.ts'
import syncRouter from './api/v1/wanikani/sync.ts'
import kanjiRouter from './api/v1/subject/kanji.ts'
import vocabRouter from './api/v1/subject/vocab.ts'
import authRouter from './api/v1/auth/index.ts'
import userRoutes from './api/v1/user/index.ts'
import { verifyAccessToken } from './middleware/auth.ts'
const allowedOrigins = [
'http://localhost:5173',
'https://srs.crylia.de',
]
const app = express()
app.use(cors())
app.use(cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true)
if (allowedOrigins.includes(origin)) return callback(null, true)
callback(new Error('Not allowed by CORS'))
},
credentials: true,
}))
app.use(express.json())
app.use(cookieParser())
if (process.env.NODE_ENV === 'production') {
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
connectSrc: ["'self'", "https://srs.crylia.de"],
},
},
crossOriginEmbedderPolicy: true,
crossOriginResourcePolicy: { policy: "same-origin" },
})
)
} else {
app.use(
helmet({
contentSecurityPolicy: false,
})
)
}
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }))
app.use('/api/v1/key', keyRouter)
app.use('/api/v1/wanikani', syncRouter)
app.use('/api/v1/subject/kanji', kanjiRouter)
app.use('/api/v1/subject/vocab', vocabRouter)
app.use('/api/v1/key', verifyAccessToken, keyRouter)
app.use('/api/v1/subject/kanji', verifyAccessToken, kanjiRouter)
app.use('/api/v1/subject/vocab', verifyAccessToken, vocabRouter)
app.use('/api/v1/user', verifyAccessToken, userRoutes)
app.use('/api/v1/wanikani', verifyAccessToken, syncRouter)
app.use('/api/v1/auth', authRouter)
const PORT = process.env.PORT || 3000
connectMongo().then(() => {

View File

@@ -0,0 +1,21 @@
import { type Request, type Response, type NextFunction } from 'express'
import jwt from 'jsonwebtoken'
export interface AuthRequest extends Request {
userId?: string
}
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!
export function verifyAccessToken(req: AuthRequest, res: Response, next: NextFunction) {
const token = req.cookies.access_token
if (!token) return res.status(401).json({ ok: false, message: 'No token provided' })
try {
const payload = jwt.verify(token, ACCESS_TOKEN_SECRET!)
req.userId = (payload as any).sub
next()
} catch {
return res.status(401).json({ ok: false, message: 'Invalid token' })
}
}

View File

@@ -1,6 +1,7 @@
import mongoose from 'mongoose'
const ApiKeySchema = new mongoose.Schema({
userId: { type: String, required: true },
apiKey: { type: String, required: true },
})

View File

@@ -1,6 +1,7 @@
import mongoose from 'mongoose'
const AssignmentSchema = new mongoose.Schema({
userId: { type: String, required: true },
subject_type: String,
subject_ids: [Number],
})

View File

@@ -2,6 +2,7 @@ import mongoose from 'mongoose'
import type { KanjiItem } from '../types/wanikani.ts'
const KanjiSchema = new mongoose.Schema<KanjiItem>({
userId: { type: String, required: true },
characters: String,
meanings: Array,
readings: Array,

View File

@@ -0,0 +1,9 @@
import mongoose from "mongoose";
const UserSchema = new mongoose.Schema({
username: { type: String, unique: true, required: true },
email: { type: String, unique: true },
refreshToken: String,
}, { timestamps: true })
export const UserModel = mongoose.model('User', UserSchema)

View File

@@ -2,6 +2,7 @@ import mongoose from 'mongoose'
import type { VocabularyItem } from '../types/wanikani.ts'
const VocabSchema = new mongoose.Schema<VocabularyItem>({
userId: { type: String, required: true },
characters: String,
meanings: Array,
readings: Array,

View File

@@ -63,7 +63,8 @@ const fetchSubjects = async (
return results
}
const mapKanji = (item: WaniKaniSubject): KanjiItem => ({
const mapKanji = (item: WaniKaniSubject, userId: string): KanjiItem => ({
userId: userId,
characters: item.data.characters,
meanings: item.data.meanings,
readings: item.data.readings,
@@ -73,7 +74,8 @@ const mapKanji = (item: WaniKaniSubject): KanjiItem => ({
srs_score: 0,
})
const mapVocab = (item: WaniKaniSubject): VocabularyItem => ({
const mapVocab = (item: WaniKaniSubject, userId: string): VocabularyItem => ({
userId: userId,
characters: item.data.characters,
meanings: item.data.meanings,
readings: item.data.readings ?? [],
@@ -84,13 +86,13 @@ const mapVocab = (item: WaniKaniSubject): VocabularyItem => ({
srs_score: 0,
})
export const syncWanikaniData = async (apiKey: string): Promise<void> => {
export const syncWanikaniData = async (apiKey: string, userId: string): Promise<void> => {
const headers = { Authorization: `Bearer ${apiKey}` }
try {
await ApiKeyModel.updateOne(
{},
{ $set: { value: apiKey, lastUsed: new Date() } },
{ $set: { user: userId, apiKey: apiKey, lastUsed: new Date() } },
{ upsert: true },
)
@@ -112,13 +114,13 @@ export const syncWanikaniData = async (apiKey: string): Promise<void> => {
}
await AssignmentModel.updateOne(
{ subject_type: 'kanji' },
{ userId: userId, subject_type: 'kanji' },
{ $set: { subject_ids: unlockedKanjiSubjectIds } },
{ upsert: true },
)
await AssignmentModel.updateOne(
{ subject_type: 'vocabulary' },
{ userId: userId, subject_type: 'vocabulary' },
{ $set: { subject_ids: unlockedVocabSubjectIds } },
{ upsert: true },
)
@@ -126,12 +128,12 @@ export const syncWanikaniData = async (apiKey: string): Promise<void> => {
const existingKanjiSlugs = new Set((await KanjiModel.find({}, { slug: 1 })).map(k => k.slug))
const kanjiSubjects = await fetchSubjects(unlockedKanjiSubjectIds, headers)
const newKanji = kanjiSubjects.filter(s => !existingKanjiSlugs.has(s.data.slug))
if (newKanji.length > 0) await KanjiModel.insertMany(newKanji.map(mapKanji))
if (newKanji.length > 0) await KanjiModel.insertMany(newKanji.map(k => mapKanji(k, userId)))
const existingVocabSlugs = new Set((await VocabularyModel.find({}, { slug: 1 })).map(v => v.slug))
const vocabSubjects = await fetchSubjects(unlockedVocabSubjectIds, headers)
const newVocab = vocabSubjects.filter(s => !existingVocabSlugs.has(s.data.slug))
if (newVocab.length > 0) await VocabularyModel.insertMany(newVocab.map(mapVocab))
if (newVocab.length > 0) await VocabularyModel.insertMany(newVocab.map(v => mapVocab(v, userId)))
console.log('✅ Sync complete')
} catch (err) {

View File

@@ -1,4 +1,5 @@
export interface KanjiItem {
userId: string
characters: string
meanings: {
meaning: string,
@@ -21,6 +22,7 @@ export interface KanjiItem {
}
export interface VocabularyItem {
userId: string
characters: string
meanings: {
meaning: string
@@ -51,6 +53,7 @@ export interface VocabularyItem {
}
export interface Assignment {
userId: string
unlocked_at?: Date
subject_ids: number[]
subject_type: string