Add SMTP auto TLS fallback to support non-SSL testing (#380)
* Enable SMTP auto TLS fallback for testing * Add HTTP fallback for account auth forms
This commit is contained in:
parent
6d19bfb762
commit
7f6c3be316
@ -101,7 +101,7 @@ var rootCmd = &cobra.Command{
|
||||
|
||||
var emailSender api.EmailSender
|
||||
if strings.TrimSpace(cfg.SMTP.Host) != "" {
|
||||
tlsMode := mailer.TLSMode(strings.ToLower(strings.TrimSpace(cfg.SMTP.TLS.Mode)))
|
||||
tlsMode := mailer.ParseTLSMode(cfg.SMTP.TLS.Mode)
|
||||
sender, err := mailer.New(mailer.Config{
|
||||
Host: cfg.SMTP.Host,
|
||||
Port: cfg.SMTP.Port,
|
||||
|
||||
@ -35,5 +35,5 @@ smtp:
|
||||
replyTo: ""
|
||||
timeout: 10s
|
||||
tls:
|
||||
mode: "starttls"
|
||||
mode: "auto"
|
||||
insecureSkipVerify: false
|
||||
|
||||
@ -79,7 +79,10 @@ type SMTP struct {
|
||||
TLS SMTPTLS `yaml:"tls"`
|
||||
}
|
||||
|
||||
// SMTPTLS describes TLS settings for SMTP connections.
|
||||
// SMTPTLS describes TLS settings for SMTP connections. Mode supports "auto",
|
||||
// "starttls", "implicit", and "none". The "auto" mode negotiates STARTTLS
|
||||
// when the server advertises support and otherwise falls back to an unencrypted
|
||||
// connection which is useful for local testing.
|
||||
type SMTPTLS struct {
|
||||
Mode string `yaml:"mode"`
|
||||
InsecureSkipVerify bool `yaml:"insecureSkipVerify"`
|
||||
|
||||
@ -23,12 +23,37 @@ type TLSMode string
|
||||
const (
|
||||
// TLSModeNone disables TLS.
|
||||
TLSModeNone TLSMode = "none"
|
||||
// TLSModeStartTLS upgrades a plain connection via STARTTLS.
|
||||
// TLSModeStartTLS upgrades a plain connection via STARTTLS and fails when unsupported.
|
||||
TLSModeStartTLS TLSMode = "starttls"
|
||||
// TLSModeImplicit establishes the connection over TLS immediately.
|
||||
TLSModeImplicit TLSMode = "implicit"
|
||||
// TLSModeAuto attempts STARTTLS when supported and gracefully falls back to plain SMTP otherwise.
|
||||
TLSModeAuto TLSMode = "auto"
|
||||
)
|
||||
|
||||
// ParseTLSMode normalises the provided value to a supported TLSMode. Unrecognised values
|
||||
// default to TLSModeAuto in order to support both secure and non-secure transports when
|
||||
// testing against simple SMTP servers.
|
||||
func ParseTLSMode(value string) TLSMode {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "", "auto", "automatic", "detect":
|
||||
return TLSModeAuto
|
||||
case "none", "disable", "disabled", "off", "plain", "plaintext":
|
||||
return TLSModeNone
|
||||
case "implicit", "smtps":
|
||||
return TLSModeImplicit
|
||||
case "starttls", "start_tls", "start-tls":
|
||||
return TLSModeStartTLS
|
||||
default:
|
||||
return TLSModeAuto
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTLSMode(mode TLSMode) TLSMode {
|
||||
return ParseTLSMode(string(mode))
|
||||
}
|
||||
|
||||
// Config contains the information required to send email via SMTP.
|
||||
type Config struct {
|
||||
Host string
|
||||
@ -95,10 +120,7 @@ func New(cfg Config) (Sender, error) {
|
||||
}
|
||||
}
|
||||
|
||||
mode := TLSMode(strings.ToLower(strings.TrimSpace(string(cfg.TLSMode))))
|
||||
if mode == "" {
|
||||
mode = TLSModeStartTLS
|
||||
}
|
||||
mode := normalizeTLSMode(cfg.TLSMode)
|
||||
|
||||
sender := &smtpSender{
|
||||
host: host,
|
||||
@ -152,11 +174,19 @@ func (s *smtpSender) Send(ctx context.Context, msg Message) error {
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if s.tlsMode == TLSModeStartTLS {
|
||||
switch s.tlsMode {
|
||||
case TLSModeStartTLS:
|
||||
tlsCfg := s.tlsConfig()
|
||||
if err := client.StartTLS(tlsCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
case TLSModeAuto:
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
tlsCfg := s.tlsConfig()
|
||||
if err := client.StartTLS(tlsCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.username != "" {
|
||||
|
||||
30
account/internal/mailer/mailer_test.go
Normal file
30
account/internal/mailer/mailer_test.go
Normal file
@ -0,0 +1,30 @@
|
||||
package mailer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTLSMode(t *testing.T) {
|
||||
cases := map[string]TLSMode{
|
||||
"": TLSModeAuto,
|
||||
"auto": TLSModeAuto,
|
||||
"automatic": TLSModeAuto,
|
||||
"detect": TLSModeAuto,
|
||||
"starttls": TLSModeStartTLS,
|
||||
"start_tls": TLSModeStartTLS,
|
||||
"start-tls": TLSModeStartTLS,
|
||||
"implicit": TLSModeImplicit,
|
||||
"smtps": TLSModeImplicit,
|
||||
"none": TLSModeNone,
|
||||
"disable": TLSModeNone,
|
||||
"disabled": TLSModeNone,
|
||||
"off": TLSModeNone,
|
||||
"plain": TLSModeNone,
|
||||
"plaintext": TLSModeNone,
|
||||
"unknown": TLSModeAuto,
|
||||
}
|
||||
|
||||
for input, expected := range cases {
|
||||
if mode := ParseTLSMode(input); mode != expected {
|
||||
t.Errorf("ParseTLSMode(%q) = %q, expected %q", input, mode, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Github } from 'lucide-react'
|
||||
@ -58,6 +58,12 @@ export default function LoginContent({ children }: LoginContentProps) {
|
||||
const wechatAuthUrl = process.env.NEXT_PUBLIC_WECHAT_AUTH_URL || '/api/auth/wechat'
|
||||
const loginUrl = process.env.NEXT_PUBLIC_LOGIN_URL || `${accountServiceBaseUrl}/api/auth/login`
|
||||
|
||||
const loginUrlRef = useRef(loginUrl)
|
||||
|
||||
useEffect(() => {
|
||||
loginUrlRef.current = loginUrl
|
||||
}, [loginUrl])
|
||||
|
||||
const socialButtonsDisabled = true
|
||||
|
||||
const initialAlert = useMemo(() => {
|
||||
@ -119,7 +125,7 @@ export default function LoginContent({ children }: LoginContentProps) {
|
||||
setAlert(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(loginUrl, {
|
||||
const requestPayload = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -130,7 +136,30 @@ export default function LoginContent({ children }: LoginContentProps) {
|
||||
password,
|
||||
remember,
|
||||
}),
|
||||
})
|
||||
} as const
|
||||
|
||||
let response: Response
|
||||
let usedUrl = loginUrlRef.current
|
||||
|
||||
try {
|
||||
response = await fetch(usedUrl, requestPayload)
|
||||
} catch (primaryError) {
|
||||
const httpsPattern = /^https:/i
|
||||
if (httpsPattern.test(usedUrl)) {
|
||||
const insecureUrl = usedUrl.replace(httpsPattern, 'http:')
|
||||
|
||||
try {
|
||||
response = await fetch(insecureUrl, requestPayload)
|
||||
loginUrlRef.current = insecureUrl
|
||||
usedUrl = insecureUrl
|
||||
} catch (fallbackError) {
|
||||
console.error('Primary login request failed, insecure fallback also failed', fallbackError)
|
||||
throw fallbackError
|
||||
}
|
||||
} else {
|
||||
throw primaryError
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorCode = 'invalid_credentials'
|
||||
@ -167,7 +196,7 @@ export default function LoginContent({ children }: LoginContentProps) {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[alerts, isSubmitting, loginUrl, normalize, router],
|
||||
[alerts, isSubmitting, normalize, router],
|
||||
)
|
||||
|
||||
const socialButtonClass = `flex w-full items-center justify-center gap-3 rounded-xl border border-gray-200 px-4 py-2.5 text-sm font-medium text-gray-800 ${
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Github } from 'lucide-react'
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import Navbar from '@components/Navbar'
|
||||
@ -28,6 +28,12 @@ export default function RegisterContent() {
|
||||
const registerUrl = process.env.NEXT_PUBLIC_REGISTER_URL || `${accountServiceBaseUrl}/api/auth/register`
|
||||
const isSocialAuthVisible = false
|
||||
|
||||
const registerUrlRef = useRef(registerUrl)
|
||||
|
||||
useEffect(() => {
|
||||
registerUrlRef.current = registerUrl
|
||||
}, [registerUrl])
|
||||
|
||||
useEffect(() => {
|
||||
const sensitiveKeys = ['username', 'password', 'confirmPassword', 'email']
|
||||
const hasSensitiveParams = sensitiveKeys.some((key) => searchParams.has(key))
|
||||
@ -130,7 +136,7 @@ export default function RegisterContent() {
|
||||
setAlert(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(registerUrl, {
|
||||
const requestPayload = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -140,7 +146,30 @@ export default function RegisterContent() {
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
} as const
|
||||
|
||||
let response: Response
|
||||
let usedUrl = registerUrlRef.current
|
||||
|
||||
try {
|
||||
response = await fetch(usedUrl, requestPayload)
|
||||
} catch (primaryError) {
|
||||
const httpsPattern = /^https:/i
|
||||
if (httpsPattern.test(usedUrl)) {
|
||||
const insecureUrl = usedUrl.replace(httpsPattern, 'http:')
|
||||
|
||||
try {
|
||||
response = await fetch(insecureUrl, requestPayload)
|
||||
registerUrlRef.current = insecureUrl
|
||||
usedUrl = insecureUrl
|
||||
} catch (fallbackError) {
|
||||
console.error('Primary register request failed, insecure fallback also failed', fallbackError)
|
||||
throw fallbackError
|
||||
}
|
||||
} else {
|
||||
throw primaryError
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorCode = 'generic_error'
|
||||
@ -181,7 +210,7 @@ export default function RegisterContent() {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[alerts, isSubmitting, normalize, registerUrl, router],
|
||||
[alerts, isSubmitting, normalize, router],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user