import { checkStringParam, errorOut, notYet, randomElement, requestIp, reverseString, hours, minutes } from '../logic/utils.js'; import { Animals } from '../misc/animals.js'; import { reissueToken, generateToken, newTokenExpiry, hashPassword, doesPasswordMatch, isEndpointAllowedForBannedUsers, isEndpointProtected, generateRestoreCode, restoreValidity, restoreAttempts } from '../logic/security.js'; import { sendRestorationLink } from '../logic/email.js'; import { UserSession } from '../logic/session.js'; import { SpamCache } from '../logic/cache.js'; /** * @param {Object} props * @param {import('fastify').FastifyInstance} props.app * @param {import('sequelize/types').Sequelize} props.db */ function AuthController({app, db}){ const { Users } = db.models; const signInSpam = new SpamCache(); const signUpSpam = new SpamCache(); const restoreApplySpam = new SpamCache(); { //validate token header and put .session in every request app.decorateRequest('session', null); app.addHook('preHandler', async (request, reply) => { if(isEndpointProtected(request.url)){ const token = request.headers.authorization; let session = UserSession.find(token); if(!session){ const userData = await Users.findOne({where: {token}}); if(userData){ session = new UserSession(userData, request); } } const tokenNotExpired = notYet(session?.user.tokenExpiry); const userNotBanned = (!notYet(session?.user.bannedUntil)) || isEndpointAllowedForBannedUsers(request.url); if(session?.user && tokenNotExpired && userNotBanned){ request.session = session; }else{ errorOut(reply, 'Unauthorized', 401); return true; //abort } } }); } app.post('/auth/sign-in', async (request, reply) => { const {email, paswd} = request.body || {}; if(signInSpam.check(requestIp(request), [[5000, 2*hours]])){ return errorOut(reply, 'error.too_fast'); } { //form validation if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); if(!isValidPassword(paswd)) return errorOut(reply, 'auth.bad_paswd'); } const fixedEmail = fixEmail(email); const user = await Users.findOne({where: {email: fixedEmail}}); if(user && doesPasswordMatch(reverseString(paswd), user.paswd)){ let token = user.token; if(new Date() > user.tokenExpiry){ token = await reissueToken(user); } return {token}; }else{ errorOut(reply, 'auth.not_found'); } }); app.post('/auth/sign-up', async (request, reply) => { const {email, username, paswd} = request.body || {}; if(signUpSpam.check(requestIp(request), [[5000, 2*hours], [100, 10*minutes]])){ return errorOut(reply, 'error.too_fast'); } { //form validation if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); if(!isValidPassword(paswd)) return errorOut(reply, 'auth.bad_paswd'); if(!isValidUsername(username)) return errorOut(reply, 'auth.bad_username'); } const fixedEmail = fixEmail(email); const fixedUsername = fixUsername(username); { //check if email is already in use const existingUser = await Users.findOne({where: {email: fixedEmail}}); if(existingUser) return errorOut(reply, 'auth.email_taken'); } { //check if username taken const existingUser = await Users.findOne({where: {username: fixedUsername}}); if(existingUser) return errorOut(reply, 'auth.username_taken'); } { //check if this ip already registered a bunch of accounts const countByIp = await Users.count({where: {firstIp: request.ip}}); if(countByIp >= 10) return errorOut(reply, 'error.suspicious'); } const newUser = { email: fixedEmail, username: fixedUsername, token: generateToken(), tokenExpiry: newTokenExpiry(), paswd: hashPassword(reverseString(paswd)), role: 'user', firstIp: requestIp(request) }; await Users.create(newUser); return {token: newUser.token}; }); app.post('/auth/restore/request', async (request, reply) => { const {email} = request.body || {}; if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); const fixedEmail = fixEmail(email); const user = await Users.findOne({where: {email: fixedEmail}}); if(!user) return errorOut(reply, 'restore.not_found'); if(user.restoreExpiry && notYet(user.restoreExpiry)){ return errorOut(reply, 'restore.already_sent'); } const restoreCode = generateRestoreCode(); const restoreExpiry = new Date(Date.now() + restoreValidity); await user.update({restoreCode, restoreExpiry}); const success = await sendRestorationLink(user, app); return {success, lifetime: restoreValidity}; }); app.post('/auth/restore/apply', async (request, reply) => { const {email, code, newpaswd} = request.body || {}; const changeRequested = (newpaswd != null); if(restoreApplySpam.check(requestIp(request), [[100, 12*hours], [25, 30*minutes]])){ return errorOut(reply, 'error.too_fast'); } if(!isValidEmail(email)) return errorOut(reply, 'auth.bad_email'); if(changeRequested && (!isValidPassword(newpaswd))){ return errorOut(reply, 'auth.bad_paswd'); } const fixedEmail = fixEmail(email); const user = await Users.findOne({where: {email: fixedEmail}}); if(!user) return errorOut(reply, 'restore.not_found'); if((!notYet(user.restoreExpiry)) || (!user.restoreCode)){ return errorOut(reply, 'restore.expired'); } if(user.restoreCode != code){ const restoreExpiry = user.restoreExpiry - (restoreValidity / restoreAttempts); await user.update({restoreExpiry}); return errorOut(reply, 'restore.wrong_code'); } if(changeRequested){ const hash = hashPassword(reverseString(newpaswd)); await user.update({paswd: hash, restoreCode: null, restoreExpiry: null}); } return '👌'; }); } function fixEmail(email){ const plusPortion = /\+.*@/m; //gmail exploit return email.toLowerCase().replace(plusPortion, ''); } function fixUsername(username){ if(username){ const profanities = /nigger|nigga|faggot/gi; const badSymbols = /[\x00-\x1F\xCC\xCD]/g; return username.replace(profanities, '💩').replace(badSymbols, ''); }else{ const nick = randomElement(Animals); const tag = Date.now().toString().slice(-4); return `Little ${nick} #${tag}`; } } function isValidEmail(email){ return checkStringParam(email, 3, 128) && !!email.match('@'); } function isValidUsername(username){ return (!username) || checkStringParam(username, 3, 64); } function isValidPassword(paswd){ return checkStringParam(paswd, 6, 256); } export default AuthController;