diff --git a/addy.go b/addy.go
index 806717c..c2878e1 100644
--- a/addy.go
+++ b/addy.go
@@ -3,13 +3,13 @@ package main
import (
"bytes"
"crypto/hmac"
+ "crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/json"
"flag"
"fmt"
"log"
- "math/rand"
"net/http"
"os"
"strings"
@@ -76,7 +76,7 @@ func addyAliasAPIAuth(r *http.Request) (*string, error) {
// Decode header
authorization, err := base32.StdEncoding.DecodeString(fields[1])
if err != nil {
- log.Println("Invalid Authorization header: %s", err.Error())
+ log.Printf("Invalid Authorization header: %s", err.Error())
return nil, err
}
@@ -89,6 +89,11 @@ func addyAliasAPIAuth(r *http.Request) (*string, error) {
}
func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
+ if !aliasLimiter.Allow(remoteIP(r)) {
+ http.Error(w, "Too many requests", http.StatusTooManyRequests)
+ return
+ }
+
user, err := addyAliasAPIAuth(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
@@ -124,6 +129,23 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
return
}
+ // Validate domain against allowlist
+ if len(allowedAliasDomains) == 0 {
+ http.Error(w, "Alias creation is not configured", http.StatusServiceUnavailable)
+ return
+ }
+ domainAllowed := false
+ for _, d := range allowedAliasDomains {
+ if body.Domain == d {
+ domainAllowed = true
+ break
+ }
+ }
+ if !domainAllowed {
+ http.Error(w, "Domain not allowed", http.StatusBadRequest)
+ return
+ }
+
if len(body.Alias) == 0 {
body.Alias = generateRandomString(10)
}
@@ -162,6 +184,11 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
}
func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) {
+ if !aliasLimiter.Allow(remoteIP(r)) {
+ http.Error(w, "Too many requests", http.StatusTooManyRequests)
+ return
+ }
+
user, err := addyAliasAPIAuth(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
@@ -203,10 +230,14 @@ func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) {
}
func generateRandomString(length int) string {
- charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
result := make([]byte, length)
- for i := range result {
- result[i] = charset[rand.Intn(len(charset))]
+ buf := make([]byte, length)
+ if _, err := rand.Read(buf); err != nil {
+ panic("crypto/rand unavailable: " + err.Error())
+ }
+ for i, b := range buf {
+ result[i] = charset[int(b)%len(charset)]
}
return string(result)
}
diff --git a/altcha.go b/altcha.go
new file mode 100644
index 0000000..ea5e50b
--- /dev/null
+++ b/altcha.go
@@ -0,0 +1,27 @@
+package main
+
+import (
+ "net/http"
+
+ goaltcha "github.com/k42-software/go-altcha"
+ altchahttp "github.com/k42-software/go-altcha/http"
+)
+
+func serveAltchaJS(w http.ResponseWriter, r *http.Request) {
+ altchahttp.ServeJavascript(w, r)
+}
+
+func serveAltchaChallenge(w http.ResponseWriter, r *http.Request) {
+ challenge := goaltcha.NewChallenge()
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
+ _, _ = w.Write([]byte(challenge.Encode()))
+}
+
+func validateAltcha(r *http.Request) bool {
+ encoded := r.PostFormValue("altcha")
+ if encoded == "" {
+ return false
+ }
+ return goaltcha.ValidateResponse(encoded, true)
+}
diff --git a/change.go b/change.go
index 08dbd39..0a9e7e6 100644
--- a/change.go
+++ b/change.go
@@ -4,46 +4,90 @@ import (
"errors"
"log"
"net/http"
+ "unicode"
)
func checkPasswdConstraint(password string) error {
- if len(password) < 8 {
- return errors.New("too short, please choose a password at least 8 characters long.")
+ if len(password) < 12 {
+ return errors.New("too short, please choose a password at least 12 characters long")
+ }
+
+ var hasUpper, hasLower, hasDigit bool
+ for _, r := range password {
+ switch {
+ case unicode.IsUpper(r):
+ hasUpper = true
+ case unicode.IsLower(r):
+ hasLower = true
+ case unicode.IsDigit(r):
+ hasDigit = true
+ }
+ }
+ if !hasUpper || !hasLower || !hasDigit {
+ return errors.New("password must contain at least one uppercase letter, one lowercase letter, and one digit")
}
return nil
}
func changePassword(w http.ResponseWriter, r *http.Request) {
- if r.Method != "POST" {
- displayTmpl(w, "change.html", map[string]interface{}{})
+ if r.Method == "POST" && !changeLimiter.Allow(remoteIP(r)) {
+ csrfToken, _ := setCSRFToken(w)
+ displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]interface{}{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken})
return
}
+ if r.Method != "POST" {
+ csrfToken, err := setCSRFToken(w)
+ if err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ displayTmpl(w, "change.html", map[string]interface{}{"csrf_token": csrfToken})
+ return
+ }
+
+ if !validateCSRF(r) {
+ csrfToken, _ := setCSRFToken(w)
+ displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken})
+ return
+ }
+
+ if !validateAltcha(r) {
+ csrfToken, _ := setCSRFToken(w)
+ displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken})
+ return
+ }
+
+ renderError := func(status int, msg string) {
+ csrfToken, _ := setCSRFToken(w)
+ displayTmplError(w, status, "change.html", map[string]interface{}{"error": msg, "csrf_token": csrfToken})
+ }
+
// Check the two new passwords are identical
if r.PostFormValue("newpassword") != r.PostFormValue("new2password") {
- displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "New passwords are not identical. Please retry."})
+ renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.")
} else if len(r.PostFormValue("login")) == 0 {
- displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "Please provide a valid login"})
+ renderError(http.StatusNotAcceptable, "Please provide a valid login")
} else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil {
- displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "The password you chose doesn't respect all constraints: " + err.Error()})
+ renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error())
} else {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
- displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()})
+ renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
} else if err := conn.ServiceBind(); err != nil {
log.Println(err)
- displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()})
+ renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
} else if dn, err := conn.SearchDN(r.PostFormValue("login"), true); err != nil {
log.Println(err)
- displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()})
+ renderError(http.StatusUnauthorized, "Invalid login or password.")
} else if err := conn.Bind(dn, r.PostFormValue("password")); err != nil {
log.Println(err)
- displayTmplError(w, http.StatusUnauthorized, "change.html", map[string]interface{}{"error": err.Error()})
+ renderError(http.StatusUnauthorized, "Invalid login or password.")
} else if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil {
log.Println(err)
- displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()})
+ renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
} else {
displayMsg(w, "Password successfully changed!", http.StatusOK)
}
diff --git a/csrf.go b/csrf.go
new file mode 100644
index 0000000..42f46d6
--- /dev/null
+++ b/csrf.go
@@ -0,0 +1,40 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "net/http"
+)
+
+func generateCSRFToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.URLEncoding.EncodeToString(b), nil
+}
+
+func setCSRFToken(w http.ResponseWriter) (string, error) {
+ token, err := generateCSRFToken()
+ if err != nil {
+ return "", err
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: "csrf_token",
+ Value: token,
+ Path: "/",
+ HttpOnly: false, // must be readable via form hidden field comparison
+ SameSite: http.SameSiteStrictMode,
+ Secure: !devMode,
+ })
+ return token, nil
+}
+
+func validateCSRF(r *http.Request) bool {
+ cookie, err := r.Cookie("csrf_token")
+ if err != nil || cookie.Value == "" {
+ return false
+ }
+ formToken := r.PostFormValue("csrf_token")
+ return formToken != "" && cookie.Value == formToken
+}
diff --git a/go.mod b/go.mod
index 17e467e..eb358eb 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,8 @@ require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/google/uuid v1.6.0 // indirect
+ github.com/k42-software/go-altcha v0.1.1
+ github.com/pkg/errors v0.9.1 // indirect
golang.org/x/crypto v0.36.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
)
diff --git a/go.sum b/go.sum
index ea772ab..318fb83 100644
--- a/go.sum
+++ b/go.sum
@@ -41,6 +41,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
+github.com/k42-software/go-altcha v0.1.1 h1:vfA+0+0gr7jK4vp21Q7xvEpIjDsx8PqzxS0obgIToQs=
+github.com/k42-software/go-altcha v0.1.1/go.mod h1:2aX+0PkUSI0YPDVfjapZeuGELWt8ugEXkg8gr6QejMU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/ldap.go b/ldap.go
index 773d1a9..85271fe 100644
--- a/ldap.go
+++ b/ldap.go
@@ -23,6 +23,7 @@ type LDAP struct {
MailPort int
MailUser string
MailPassword string
+ MailFrom string
}
func (l LDAP) Connect() (*LDAPConn, error) {
@@ -74,7 +75,7 @@ func (l LDAPConn) SearchDN(username string, person bool) (string, error) {
searchRequest := ldap.NewSearchRequest(
l.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
- fmt.Sprintf("(&(objectClass=%s)(uid=%s))", objectClass, username),
+ fmt.Sprintf("(&(objectClass=%s)(uid=%s))", ldap.EscapeFilter(objectClass), ldap.EscapeFilter(username)),
[]string{"dn"},
nil,
)
@@ -147,7 +148,7 @@ func (l LDAPConn) SearchMailAlias(address string) (int, error) {
searchRequest := ldap.NewSearchRequest(
l.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
- fmt.Sprintf("(&(objectClass=*)(mailAlias=%s))", address),
+ fmt.Sprintf("(&(objectClass=*)(mailAlias=%s))", ldap.EscapeFilter(address)),
[]string{"dn"},
nil,
)
diff --git a/login.go b/login.go
index 8329600..44ffe66 100644
--- a/login.go
+++ b/login.go
@@ -2,9 +2,11 @@ package main
import (
"fmt"
+ "html"
"html/template"
"log"
"net/http"
+ "net/url"
"strings"
"github.com/go-ldap/ldap/v3"
@@ -46,6 +48,16 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
return
}
+ if !authLimiter.Allow(remoteIP(r)) {
+ displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]interface{}{"error": "Too many login attempts. Please try again later."})
+ return
+ }
+
+ if !validateAltcha(r) {
+ displayTmplError(w, http.StatusForbidden, "login.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."})
+ return
+ }
+
if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil {
log.Println(err)
displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()})
@@ -55,20 +67,34 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
cnt := "
"
for _, e := range entries {
for i, v := range e.Values {
+ safeName := html.EscapeString(e.Name)
+ safeVal := html.EscapeString(v)
+ elemID := fmt.Sprintf("mailAlias-%d", i)
if e.Name == "userPassword" || e.Name == "krbPrincipalKey" {
- cnt += "- " + e.Name + ": [...]
"
+ cnt += "- " + safeName + ": [...]
"
} else if e.Name == "mailAlias" && len(strings.SplitN(v, "@", 2)[0]) == 10 {
- cnt += "- " + e.Name + ": " + v + `
`
+ safeURL := url.PathEscape(v)
+ safeToken := html.EscapeString(apiToken)
+ safeElemID := html.EscapeString(elemID)
+ cnt += `- ` + safeName + `: ` + safeVal +
+ `
`
} else {
- cnt += "- " + e.Name + ": " + v + "
"
+ cnt += "- " + safeName + ": " + safeVal + "
"
}
}
}
- displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok
Here are the information we have about you:` + cnt + "
To use our Addy.io compatible API, use the following token: " + apiToken + "
")})
+ displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok
Here are the information we have about you:` + cnt + "To use our Addy.io compatible API, use the following token: " + html.EscapeString(apiToken) + "
")})
}
}
func httpBasicAuth(w http.ResponseWriter, r *http.Request) {
+ if !authLimiter.Allow(remoteIP(r)) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`)
+ w.WriteHeader(http.StatusTooManyRequests)
+ w.Write([]byte("Too many requests"))
+ return
+ }
+
if user, pass, ok := r.BasicAuth(); ok {
if entries, err := login(user, pass); err != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`)
@@ -87,7 +113,7 @@ func httpBasicAuth(w http.ResponseWriter, r *http.Request) {
}
return
}
- } else if v := r.Header.Get("X-Special-Auth"); v == "docker-registry" {
+ } else if dockerRegistrySecret != "" && r.Header.Get("X-Special-Auth") == dockerRegistrySecret {
method := r.Header.Get("X-Original-Method")
uri := r.Header.Get("X-Original-URI")
diff --git a/lost.go b/lost.go
index 0ddd36f..1e4c9d3 100644
--- a/lost.go
+++ b/lost.go
@@ -1,54 +1,64 @@
package main
import (
- "crypto/sha512"
+ "crypto/rand"
"encoding/base64"
- "encoding/binary"
"io"
"log"
"net/http"
"os"
"os/exec"
+ "sync"
"time"
"gopkg.in/gomail.v2"
)
-func (l LDAPConn) genToken(dn string, previous bool) string {
- hour := time.Now()
- // Generate the previous token?
- if previous {
- hour.Add(time.Hour * -1)
+type resetTokenEntry struct {
+ dn string
+ expiresAt time.Time
+}
+
+var resetTokenStore = struct {
+ mu sync.Mutex
+ tokens map[string]resetTokenEntry
+}{tokens: make(map[string]resetTokenEntry)}
+
+func generateResetToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
}
+ return base64.URLEncoding.EncodeToString(b), nil
+}
- b := make([]byte, binary.MaxVarintLen64)
- binary.PutVarint(b, hour.Round(time.Hour).Unix())
+func storeResetToken(token string, dn string) {
+ resetTokenStore.mu.Lock()
+ defer resetTokenStore.mu.Unlock()
- // Search the email address and current password
- entries, err := l.GetEntry(dn)
- if err != nil {
- log.Println("Unable to generate token:", err)
- return "#err"
- }
-
- email := ""
- curpasswd := ""
- for _, e := range entries {
- if e.Name == "mail" {
- email += e.Values[0]
- } else if e.Name == "userPassword" {
- curpasswd += e.Values[0]
+ // Clean expired tokens
+ now := time.Now()
+ for t, e := range resetTokenStore.tokens {
+ if now.After(e.expiresAt) {
+ delete(resetTokenStore.tokens, t)
}
}
+ resetTokenStore.tokens[token] = resetTokenEntry{
+ dn: dn,
+ expiresAt: now.Add(time.Hour),
+ }
+}
- // Hash that
- hash := sha512.New()
- hash.Write(b)
- hash.Write([]byte(dn))
- hash.Write([]byte(email))
- hash.Write([]byte(curpasswd))
-
- return base64.StdEncoding.EncodeToString(hash.Sum(nil)[:])
+func consumeResetToken(token string) (string, bool) {
+ resetTokenStore.mu.Lock()
+ defer resetTokenStore.mu.Unlock()
+ entry, ok := resetTokenStore.tokens[token]
+ if !ok || time.Now().After(entry.expiresAt) {
+ delete(resetTokenStore.tokens, token)
+ return "", false
+ }
+ delete(resetTokenStore.tokens, token)
+ return entry.dn, true
}
func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) {
@@ -64,15 +74,41 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) {
return "", "", err
}
- // Generate the token
- token := conn.genToken(dn, false)
+ // Generate a cryptographically random token
+ token, err := generateResetToken()
+ if err != nil {
+ return "", "", err
+ }
+
+ // Store token server-side with expiration
+ storeResetToken(token, dn)
return token, dn, nil
}
func lostPassword(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "POST" && !lostLimiter.Allow(remoteIP(r)) {
+ displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]interface{}{"error": "Too many requests. Please try again later."})
+ return
+ }
+
if r.Method != "POST" {
- displayTmpl(w, "lost.html", map[string]interface{}{})
+ csrfToken, err := setCSRFToken(w)
+ if err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ displayTmpl(w, "lost.html", map[string]interface{}{"csrf_token": csrfToken})
+ return
+ }
+
+ if !validateCSRF(r) {
+ displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again."})
+ return
+ }
+
+ if !validateAltcha(r) {
+ displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."})
return
}
@@ -80,7 +116,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
- displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()})
+ displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to process your request. Please try again later."})
return
}
@@ -88,7 +124,8 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
token, dn, err := lostPasswordToken(conn, r.PostFormValue("login"))
if err != nil {
log.Println(err)
- displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()})
+ // Return generic message to avoid user enumeration
+ displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
return
}
@@ -96,7 +133,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
entries, err := conn.GetEntry(dn)
if err != nil {
log.Println(err)
- displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()})
+ displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
return
}
@@ -113,16 +150,16 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
if email == "" {
log.Println("Unable to find a valid adress for user " + dn)
- displayTmplError(w, http.StatusBadRequest, "lost.html", map[string]interface{}{"error": "We were unable to find a valid email address associated with your account. Please contact an administrator."})
+ displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
return
}
// Send the email
m := gomail.NewMessage()
- m.SetHeader("From", "noreply@nemunai.re")
+ m.SetHeader("From", myLDAP.MailFrom)
m.SetHeader("To", email)
m.SetHeader("Subject", "SSO nemunai.re: password recovery")
- m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\n"+BASEURL+"/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nBest regards,\n-- \nnemunai.re SSO")
+ m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\n"+myPublicURL+"/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nThis link expires in 1 hour and can only be used once.\n\nBest regards,\n-- \nnemunai.re SSO")
var s gomail.Sender
if myLDAP.MailHost != "" {
@@ -130,7 +167,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
s, err = d.Dial()
if err != nil {
log.Println("Unable to connect to email server: " + err.Error())
- displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to connect to email server: " + err.Error()})
+ displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."})
return
}
} else {
@@ -165,7 +202,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
if err := gomail.Send(s, m); err != nil {
log.Println("Unable to send email: " + err.Error())
- displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send email: " + err.Error()})
+ displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."})
return
}
diff --git a/main.go b/main.go
index 9ab3e58..36ab61d 100644
--- a/main.go
+++ b/main.go
@@ -17,13 +17,23 @@ import (
"syscall"
)
-const BASEURL = "https://ldap.nemunai.re"
+var myPublicURL = "https://ldap.nemunai.re"
+var devMode bool
+
+// dockerRegistrySecret is required for X-Special-Auth anonymous access.
+// If empty, the feature is disabled.
+var dockerRegistrySecret string
+
+// allowedAliasDomains is the allowlist of domains users may create aliases under.
+// If empty, alias creation is disabled.
+var allowedAliasDomains []string
var myLDAP = LDAP{
Host: "localhost",
Port: 389,
BaseDN: "dc=example,dc=com",
MailPort: 587,
+ MailFrom: "noreply@nemunai.re",
}
type ResponseWriterPrefix struct {
@@ -70,8 +80,16 @@ func main() {
var bind = flag.String("bind", "127.0.0.1:8080", "Bind port/socket")
var baseURL = flag.String("baseurl", "/", "URL prepended to each URL")
var configfile = flag.String("config", "", "path to the configuration file")
+ var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails")
+ var dev = flag.Bool("dev", false, "Development mode: disables HSTS and cookie Secure flag for local HTTP testing")
flag.Parse()
+ myPublicURL = *publicURL
+ devMode = *dev
+ if devMode {
+ log.Println("WARNING: running in development mode — security features relaxed, do not use in production")
+ }
+
// Sanitize options
log.Println("Checking paths...")
if *baseURL != "/" {
@@ -141,9 +159,31 @@ func main() {
if val, ok := os.LookupEnv("SMTP_USER"); ok {
myLDAP.MailUser = val
}
- if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok {
+ if val, ok := os.LookupEnv("SMTP_PASSWORD_FILE"); ok {
+ if fd, err := os.Open(val); err != nil {
+ log.Fatal(err)
+ } else if cnt, err := os.ReadFile(val); err != nil {
+ fd.Close()
+ log.Fatal(err)
+ } else {
+ fd.Close()
+ myLDAP.MailPassword = string(cnt)
+ }
+ } else if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok {
myLDAP.MailPassword = val
}
+ if val, ok := os.LookupEnv("SMTP_FROM"); ok {
+ myLDAP.MailFrom = val
+ }
+ if val, ok := os.LookupEnv("PUBLIC_URL"); ok {
+ myPublicURL = val
+ }
+ if val, ok := os.LookupEnv("DOCKER_REGISTRY_SECRET"); ok {
+ dockerRegistrySecret = val
+ }
+ if val, ok := os.LookupEnv("ALIAS_ALLOWED_DOMAINS"); ok && val != "" {
+ allowedAliasDomains = strings.Split(val, ",")
+ }
if flag.NArg() > 0 {
switch flag.Arg(0) {
@@ -164,7 +204,7 @@ func main() {
log.Fatal(err.Error())
}
- fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, BASEURL, login, token)
+ fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, myPublicURL, login, token)
return
case "serve":
case "server":
@@ -179,6 +219,8 @@ func main() {
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
// Register handlers
+ http.HandleFunc(fmt.Sprintf("GET %s/altcha.min.js", *baseURL), serveAltchaJS)
+ http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", *baseURL), serveAltchaChallenge)
http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword)
http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI)
http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", *baseURL), addyAliasAPIDelete)
@@ -189,7 +231,8 @@ func main() {
http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword)
srv := &http.Server{
- Addr: *bind,
+ Addr: *bind,
+ Handler: securityHeaders(http.DefaultServeMux),
}
// Serve content
diff --git a/ratelimit.go b/ratelimit.go
new file mode 100644
index 0000000..28ad50f
--- /dev/null
+++ b/ratelimit.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "net"
+ "net/http"
+ "sync"
+ "time"
+)
+
+type rateLimiter struct {
+ mu sync.Mutex
+ counts map[string][]time.Time
+ limit int
+ window time.Duration
+}
+
+func newRateLimiter(limit int, window time.Duration) *rateLimiter {
+ return &rateLimiter{
+ counts: make(map[string][]time.Time),
+ limit: limit,
+ window: window,
+ }
+}
+
+func (rl *rateLimiter) Allow(key string) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ now := time.Now()
+ windowStart := now.Add(-rl.window)
+
+ timestamps := rl.counts[key]
+ filtered := timestamps[:0]
+ for _, t := range timestamps {
+ if t.After(windowStart) {
+ filtered = append(filtered, t)
+ }
+ }
+
+ if len(filtered) >= rl.limit {
+ rl.counts[key] = filtered
+ return false
+ }
+
+ rl.counts[key] = append(filtered, now)
+ return true
+}
+
+var (
+ authLimiter = newRateLimiter(20, time.Minute)
+ changeLimiter = newRateLimiter(10, time.Minute)
+ lostLimiter = newRateLimiter(5, time.Minute)
+ resetLimiter = newRateLimiter(10, time.Minute)
+ aliasLimiter = newRateLimiter(30, time.Minute)
+)
+
+func remoteIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
diff --git a/reset.go b/reset.go
index 87dfc76..a37f99d 100644
--- a/reset.go
+++ b/reset.go
@@ -3,32 +3,66 @@ package main
import (
"log"
"net/http"
- "strings"
)
func resetPassword(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "POST" && !resetLimiter.Allow(remoteIP(r)) {
+ http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
+ return
+ }
+
if len(r.URL.Query().Get("l")) == 0 || len(r.URL.Query().Get("t")) == 0 {
http.Redirect(w, r, "lost", http.StatusFound)
+ return
}
base := map[string]interface{}{
"login": r.URL.Query().Get("l"),
- "token": strings.Replace(r.URL.Query().Get("t"), " ", "+", -1),
+ "token": r.URL.Query().Get("t"),
}
if r.Method != "POST" {
+ csrfToken, err := setCSRFToken(w)
+ if err != nil {
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ base["csrf_token"] = csrfToken
displayTmpl(w, "reset.html", base)
return
}
+ renderError := func(status int, msg string) {
+ csrfToken, _ := setCSRFToken(w)
+ base["error"] = msg
+ base["csrf_token"] = csrfToken
+ displayTmplError(w, status, "reset.html", base)
+ }
+
+ if !validateCSRF(r) {
+ renderError(http.StatusForbidden, "Invalid or missing CSRF token. Please try again.")
+ return
+ }
+
+ if !validateAltcha(r) {
+ renderError(http.StatusForbidden, "Invalid or missing altcha response. Please try again.")
+ return
+ }
+
// Check the two new passwords are identical
if r.PostFormValue("newpassword") != r.PostFormValue("new2password") {
- base["error"] = "New passwords are not identical. Please retry."
- displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
+ renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.")
return
} else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil {
- base["error"] = "The password you chose doesn't respect all constraints: " + err.Error()
- displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
+ renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error())
+ return
+ }
+
+ // Validate and consume the token (single-use, server-side)
+ token := r.PostFormValue("token")
+ dn, ok := consumeResetToken(token)
+ if !ok {
+ renderError(http.StatusNotAcceptable, "Token invalid or expired, please retry the lost password procedure. Tokens expire after 1 hour.")
return
}
@@ -36,41 +70,22 @@ func resetPassword(w http.ResponseWriter, r *http.Request) {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
- base["error"] = err.Error()
- displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
+ renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}
- // Bind as service to perform the search
+ // Bind as service to perform the password change
err = conn.ServiceBind()
if err != nil {
log.Println(err)
- base["error"] = err.Error()
- displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
- return
- }
-
- // Search the dn of the given user
- dn, err := conn.SearchDN(r.PostFormValue("login"), true)
- if err != nil {
- log.Println(err)
- base["error"] = err.Error()
- displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
- return
- }
-
- // Check token validity (allow current token + last one)
- if conn.genToken(dn, false) != r.PostFormValue("token") && conn.genToken(dn, true) != r.PostFormValue("token") {
- base["error"] = "Token invalid, please retry the lost password procedure. Please note that our token expires after 1 hour."
- displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
+ renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}
// Replace the password by the new given
if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil {
log.Println(err)
- base["error"] = err.Error()
- displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
+ renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
return
}
diff --git a/static.go b/static.go
index 0570d91..4946083 100644
--- a/static.go
+++ b/static.go
@@ -7,6 +7,19 @@ import (
"net/http"
)
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com")
+ if !devMode {
+ w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
//go:embed all:static
var assets embed.FS
diff --git a/static/change.html b/static/change.html
index 019e7f5..424b171 100644
--- a/static/change.html
+++ b/static/change.html
@@ -3,6 +3,7 @@
diff --git a/static/header.html b/static/header.html
index ba37563..f86b55b 100644
--- a/static/header.html
+++ b/static/header.html
@@ -10,6 +10,7 @@
nemunai.re password change
+
diff --git a/static/login.html b/static/login.html
index b8c9366..ec0678a 100644
--- a/static/login.html
+++ b/static/login.html
@@ -9,6 +9,9 @@
+
Forgot your password?
diff --git a/static/lost.html b/static/lost.html
index 7b6e2e7..bba8878 100644
--- a/static/lost.html
+++ b/static/lost.html
@@ -3,9 +3,13 @@
diff --git a/static/reset.html b/static/reset.html
index dac37d7..382ab00 100644
--- a/static/reset.html
+++ b/static/reset.html
@@ -1,8 +1,9 @@
{{template "header"}}
Forgot your password? Define a new one!
-
{{template "footer"}}