Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ SMTP Authentication #80

Merged
merged 9 commits into from
Mar 24, 2024
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "^_"}]
}
}
90 changes: 86 additions & 4 deletions src/smtp/SMTPServer.ts
Original file line number Diff line number Diff line change
@@ -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")

Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -138,11 +159,72 @@ 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", () => {
logger.log("Client disconnected")
})
}

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")
}

}
3 changes: 2 additions & 1 deletion src/smtp/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const STATUS_CODES: Record<number, StatusCode> = {
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" },
Expand All @@ -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[]
Expand Down
Loading