Skip to content

Commit

Permalink
✨ SMTP Authentication (#80)
Browse files Browse the repository at this point in the history
* ✨ SMTP AUTH PLAIN command

* 🔧 Allow _ var names in destructuring

* ✨ Enforce SMTP authentication in MAIL FROM command

* ✨ Allow sending credentials with AUTH PLAIN command

* Update src/smtp/SMTPServer.ts

Co-authored-by: j0code <[email protected]>

* 🎨 Format it according to new ESLint rules

* ♻️ Refactor SMTPServer authentication state

* ♻️ Refactor email extraction logic in SMTPServer.ts

---------

Co-authored-by: j0code <[email protected]>
  • Loading branch information
cfpwastaken and j0code authored Mar 24, 2024
1 parent de4c931 commit 6a799fd
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 6 deletions.
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

0 comments on commit 6a799fd

Please sign in to comment.