diff --git a/.eslintrc.json b/.eslintrc.json index bbfe948..650f7eb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -294,6 +294,7 @@ "wrap-regex": "warn", "yield-star-spacing": ["warn", "after"], - "@typescript-eslint/no-empty-function": "off" + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-unused-vars": ["warn", {"destructuredArrayIgnorePattern": "^_"}] } } diff --git a/src/smtp/SMTPServer.ts b/src/smtp/SMTPServer.ts index 007579b..b554de4 100644 --- a/src/smtp/SMTPServer.ts +++ b/src/smtp/SMTPServer.ts @@ -1,10 +1,11 @@ +import sendStatus, { type StatusOptions } from "./status.js" import Logger from "../Logger.js" import SMTP from "./SMTP.js" import User from "../models/User.js" import getConfig from "../config.js" import net from "net" -import sendStatus from "./status.js" import tls from "tls" +import { verify } from "argon2" const logger = new Logger("SMTPServer", "GREEN") @@ -39,6 +40,14 @@ export default class SMTPServer { content: "" } + const auth: { + state: "none" | "waiting" | "authenticated", + user: string + } = { + state: "none", + user: "" + } + sock.on("data", async (data: Buffer) => { const msg = data.toString() @@ -63,8 +72,7 @@ export default class SMTPServer { logger.log(`Received data: ${msg}`) if (msg.startsWith("EHLO")) { sock.write(`250-${getConfig("host", "localhost")}\r\n`) - - // We dont have any smtp extensions yet + sock.write(`250-AUTH PLAIN\r\n`) status(250, { message: "HELP" }) // was: 250 HELP } else if (msg.startsWith("MAIL FROM:")) { // The spec says we should reset the state if the client sends MAIL FROM again @@ -73,7 +81,20 @@ export default class SMTPServer { to: [], content: "" } - const email = msg.split(":")[1].split(">")[0].replace("<", "") + + const email = msg.substring(msg.indexOf("<") + 1, msg.lastIndexOf(">")) + + if (email.endsWith(`@${getConfig("host")}`) && auth.user != email) { + // RFC 4954 Section 6: + // 530 5.7.0 Authentication required + // This response SHOULD be returned by any command other than AUTH, EHLO, HELO, + // NOOP, RSET, or QUIT when server policy requires + // authentication in order to perform the requested action and + // authentication is not currently in force. + status(530, "5.7.0") + + return + } logger.log(`MAIL FROM: ${email}`) info.from = email @@ -138,6 +159,7 @@ export default class SMTPServer { // but that can be a security risk + it is also done with RCPT TO anyway status(502) } else if (msg.startsWith("EXPN")) status(502) + else if (msg.startsWith("AUTH PLAIN") || auth.state == "waiting") await SMTPServer.authPlain(msg, auth, info, status) else status(502) }) sock.on("close", () => { @@ -145,4 +167,64 @@ export default class SMTPServer { }) } + static async authPlain( + msg: string, auth: { state: "none" | "waiting" | "authenticated", user: string }, info: { from: string, to: string[], content: string }, + status: (code: number, options?: StatusOptions | `${bigint}.${bigint}.${bigint}` | undefined) => void + ) { + if (auth.state == "authenticated" || info.from != "" || info.to.length != 0 || info.content != "") { + // RFC 4954 Section 4: + // After a successful AUTH command completes, a server MUST reject any + // further AUTH commands with a 503 reply. + // RFC 4954 Section 4: + // The AUTH command is not permitted during a mail transaction. + // An AUTH command issued during a mail transaction MUST be rejected with a 503 reply. + status(503) + } + + if (auth.state == "none") { + if (msg.split(" ").length == 3) { + await SMTPServer.authenticateUser(msg.split(" ")[2], auth, status) + + return + } + + status(334) + auth.state = "waiting" + } else if (auth.state == "waiting") await SMTPServer.authenticateUser(msg, auth, status) + } + + static async authenticateUser( + msg: string, auth: { state: "none" | "waiting" | "authenticated", user: string }, + status: (code: number, options?: StatusOptions | `${bigint}.${bigint}.${bigint}` | undefined) => void + ) { + const [_, username, password] = Buffer.from(msg, "base64").toString().split("\0") + + if (!username || !password) { + status(501, "5.5.2") + auth.state = "none" + + return + } + + const user = await User.findOne({ where: { username } }) + + if (!user) { + status(535) + auth.state = "none" + + return + } + + if (!(await verify(user.password, password))) { + status(535) + auth.state = "none" + + return + } + + auth.state = "authenticated" + auth.user = `${username}@${getConfig("host")}` + status(235, "2.7.0") + } + } diff --git a/src/smtp/status.ts b/src/smtp/status.ts index 3004f48..e924a93 100644 --- a/src/smtp/status.ts +++ b/src/smtp/status.ts @@ -61,6 +61,7 @@ const STATUS_CODES: Record = { 504: { code: 504, ok: false, message: "Command parameter is not implemented" }, 521: { code: 521, ok: false, message: "Server does not accept mail" }, 523: { code: 523, ok: false, message: "Encryption Needed" }, + 535: { code: 535, ok: false, message: "Authentication credentials invalid" }, 550: { code: 550, ok: false, message: "Requested action not taken: mailbox unavailable" }, 551: { code: 551, ok: false, message: "User not local; please try %" }, 552: { code: 552, ok: false, message: "Requested mail action aborted: exceeded storage allocation" }, @@ -86,7 +87,7 @@ const ENHANCED_STATUS_CODES: Record<`${number} ${EnhancedCode}`, EnhancedStatusC "554 5.3.4": { code: 554, ok: false, message: "Message too big for system", class: 5, subject: 7, detail: 8 } } -type StatusOptions = { +export type StatusOptions = { message?: string, enhancedCode?: EnhancedCode, args?: string[]