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 @@ -289,6 +289,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": "^_"}]
}
}
84 changes: 81 additions & 3 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,15 @@
content: ""
}

// State:
// 0: No authentication
// 1: Waiting for authentication (AUTH PLAIN)
// 2: Authenticated
const auth = {
state: 0,
cfpwastaken marked this conversation as resolved.
Show resolved Hide resolved
user: ""
}

sock.on("data", async (data: Buffer) => {
const msg = data.toString()

Expand All @@ -63,8 +73,7 @@
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 @@ -75,6 +84,18 @@
}
const email = msg.split(":")[1].split(">")[0].replace("<", "")
cfpwastaken marked this conversation as resolved.
Show resolved Hide resolved

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
status(250)
Expand Down Expand Up @@ -138,11 +159,68 @@
// 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 == 1) await SMTPServer.authPlain(msg, auth, info, status)
else status(502)
})
sock.on("close", () => {
logger.log("Client disconnected")
})
}

static async authPlain(msg: string, auth: { state: number, user: string }, info: { from: string, to: string[], content: string },
Fixed Show fixed Hide fixed
status: (code: number, options?: StatusOptions | `${bigint}.${bigint}.${bigint}` | undefined) => void) {
Fixed Show fixed Hide fixed
if (auth.state == 2 || 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 == 0) {
if (msg.split(" ").length == 3) {
await SMTPServer.authenticateUser(msg.split(" ")[2], auth, status)

return
}

status(334)
auth.state = 1
} else if (auth.state == 1) await SMTPServer.authenticateUser(msg, auth, status)
}

static async authenticateUser(msg: string, auth: { state: number, user: string },
Fixed Show fixed Hide fixed
status: (code: number, options?: StatusOptions | `${bigint}.${bigint}.${bigint}` | undefined) => void) {
Fixed Show fixed Hide fixed
const [_, username, password] = Buffer.from(msg, "base64").toString().split("\0")

if (!username || !password) {
status(501, "5.5.2")
auth.state = 0

return
}

const user = await User.findOne({ where: { username } })

if (!user) {
status(535)
auth.state = 0

return
}

if (!(await verify(user.password, password))) {
status(535)
auth.state = 0

return
}

auth.state = 2
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