diff --git a/addy.go b/addy.go index c2878e1..806717c 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.Printf("Invalid Authorization header: %s", err.Error()) + log.Println("Invalid Authorization header: %s", err.Error()) return nil, err } @@ -89,11 +89,6 @@ 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) @@ -129,23 +124,6 @@ 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) } @@ -184,11 +162,6 @@ 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) @@ -230,14 +203,10 @@ func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) { } func generateRandomString(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" result := make([]byte, length) - 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)] + for i := range result { + result[i] = charset[rand.Intn(len(charset))] } return string(result) } diff --git a/altcha.go b/altcha.go deleted file mode 100644 index ea5e50b..0000000 --- a/altcha.go +++ /dev/null @@ -1,27 +0,0 @@ -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 0a9e7e6..08dbd39 100644 --- a/change.go +++ b/change.go @@ -4,90 +4,46 @@ import ( "errors" "log" "net/http" - "unicode" ) func checkPasswdConstraint(password string) error { - 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") + if len(password) < 8 { + return errors.New("too short, please choose a password at least 8 characters long.") } return nil } func changePassword(w http.ResponseWriter, r *http.Request) { - 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}) + displayTmpl(w, "change.html", map[string]interface{}{}) 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") { - renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.") + displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "New passwords are not identical. Please retry."}) } else if len(r.PostFormValue("login")) == 0 { - renderError(http.StatusNotAcceptable, "Please provide a valid login") + displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "Please provide a valid login"}) } else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil { - renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error()) + displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "The password you chose doesn't respect all constraints: " + err.Error()}) } else { conn, err := myLDAP.Connect() if err != nil || conn == nil { log.Println(err) - renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") + displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()}) } else if err := conn.ServiceBind(); err != nil { log.Println(err) - renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") + displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()}) } else if dn, err := conn.SearchDN(r.PostFormValue("login"), true); err != nil { log.Println(err) - renderError(http.StatusUnauthorized, "Invalid login or password.") + displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()}) } else if err := conn.Bind(dn, r.PostFormValue("password")); err != nil { log.Println(err) - renderError(http.StatusUnauthorized, "Invalid login or password.") + displayTmplError(w, http.StatusUnauthorized, "change.html", map[string]interface{}{"error": err.Error()}) } else if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil { log.Println(err) - renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") + displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()}) } else { displayMsg(w, "Password successfully changed!", http.StatusOK) } diff --git a/csrf.go b/csrf.go deleted file mode 100644 index 42f46d6..0000000 --- a/csrf.go +++ /dev/null @@ -1,40 +0,0 @@ -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 eb358eb..17e467e 100644 --- a/go.mod +++ b/go.mod @@ -14,8 +14,6 @@ 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 318fb83..ea772ab 100644 --- a/go.sum +++ b/go.sum @@ -41,10 +41,6 @@ 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 85271fe..773d1a9 100644 --- a/ldap.go +++ b/ldap.go @@ -23,7 +23,6 @@ type LDAP struct { MailPort int MailUser string MailPassword string - MailFrom string } func (l LDAP) Connect() (*LDAPConn, error) { @@ -75,7 +74,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))", ldap.EscapeFilter(objectClass), ldap.EscapeFilter(username)), + fmt.Sprintf("(&(objectClass=%s)(uid=%s))", objectClass, username), []string{"dn"}, nil, ) @@ -148,7 +147,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))", ldap.EscapeFilter(address)), + fmt.Sprintf("(&(objectClass=*)(mailAlias=%s))", address), []string{"dn"}, nil, ) diff --git a/login.go b/login.go index 44ffe66..8329600 100644 --- a/login.go +++ b/login.go @@ -2,11 +2,9 @@ package main import ( "fmt" - "html" "html/template" "log" "net/http" - "net/url" "strings" "github.com/go-ldap/ldap/v3" @@ -48,16 +46,6 @@ 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()}) @@ -67,34 +55,20 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { cnt := "

To use our Addy.io compatible API, use the following token: " + html.EscapeString(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: " + 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"`) @@ -113,7 +87,7 @@ func httpBasicAuth(w http.ResponseWriter, r *http.Request) { } return } - } else if dockerRegistrySecret != "" && r.Header.Get("X-Special-Auth") == dockerRegistrySecret { + } else if v := r.Header.Get("X-Special-Auth"); v == "docker-registry" { method := r.Header.Get("X-Original-Method") uri := r.Header.Get("X-Original-URI") diff --git a/lost.go b/lost.go index 1e4c9d3..0ddd36f 100644 --- a/lost.go +++ b/lost.go @@ -1,64 +1,54 @@ package main import ( - "crypto/rand" + "crypto/sha512" "encoding/base64" + "encoding/binary" "io" "log" "net/http" "os" "os/exec" - "sync" "time" "gopkg.in/gomail.v2" ) -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 +func (l LDAPConn) genToken(dn string, previous bool) string { + hour := time.Now() + // Generate the previous token? + if previous { + hour.Add(time.Hour * -1) } - return base64.URLEncoding.EncodeToString(b), nil -} -func storeResetToken(token string, dn string) { - resetTokenStore.mu.Lock() - defer resetTokenStore.mu.Unlock() + b := make([]byte, binary.MaxVarintLen64) + binary.PutVarint(b, hour.Round(time.Hour).Unix()) - // Clean expired tokens - now := time.Now() - for t, e := range resetTokenStore.tokens { - if now.After(e.expiresAt) { - delete(resetTokenStore.tokens, t) + // 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] } } - resetTokenStore.tokens[token] = resetTokenEntry{ - dn: dn, - expiresAt: now.Add(time.Hour), - } -} -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 + // 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 lostPasswordToken(conn *LDAPConn, login string) (string, string, error) { @@ -74,41 +64,15 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) { return "", "", err } - // Generate a cryptographically random token - token, err := generateResetToken() - if err != nil { - return "", "", err - } - - // Store token server-side with expiration - storeResetToken(token, dn) + // Generate the token + token := conn.genToken(dn, false) 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" { - 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."}) + displayTmpl(w, "lost.html", map[string]interface{}{}) return } @@ -116,7 +80,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": "Unable to process your request. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) return } @@ -124,8 +88,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { token, dn, err := lostPasswordToken(conn, r.PostFormValue("login")) if err != nil { log.Println(err) - // 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) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) return } @@ -133,7 +96,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { entries, err := conn.GetEntry(dn) if err != nil { log.Println(err) - displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) return } @@ -150,16 +113,16 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { if email == "" { log.Println("Unable to find a valid adress for user " + dn) - displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK) + 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."}) return } // Send the email m := gomail.NewMessage() - m.SetHeader("From", myLDAP.MailFrom) + m.SetHeader("From", "noreply@nemunai.re") 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"+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") + 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") var s gomail.Sender if myLDAP.MailHost != "" { @@ -167,7 +130,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 send password recovery email. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to connect to email server: " + err.Error()}) return } } else { @@ -202,7 +165,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 password recovery email. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send email: " + err.Error()}) return } diff --git a/main.go b/main.go index 36ab61d..9ab3e58 100644 --- a/main.go +++ b/main.go @@ -17,23 +17,13 @@ import ( "syscall" ) -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 +const BASEURL = "https://ldap.nemunai.re" var myLDAP = LDAP{ Host: "localhost", Port: 389, BaseDN: "dc=example,dc=com", MailPort: 587, - MailFrom: "noreply@nemunai.re", } type ResponseWriterPrefix struct { @@ -80,16 +70,8 @@ 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 != "/" { @@ -159,31 +141,9 @@ func main() { if val, ok := os.LookupEnv("SMTP_USER"); ok { myLDAP.MailUser = val } - 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 { + 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) { @@ -204,7 +164,7 @@ func main() { log.Fatal(err.Error()) } - fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, myPublicURL, login, token) + fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, BASEURL, login, token) return case "serve": case "server": @@ -219,8 +179,6 @@ 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) @@ -231,8 +189,7 @@ func main() { http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword) srv := &http.Server{ - Addr: *bind, - Handler: securityHeaders(http.DefaultServeMux), + Addr: *bind, } // Serve content diff --git a/ratelimit.go b/ratelimit.go deleted file mode 100644 index 28ad50f..0000000 --- a/ratelimit.go +++ /dev/null @@ -1,63 +0,0 @@ -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 a37f99d..87dfc76 100644 --- a/reset.go +++ b/reset.go @@ -3,66 +3,32 @@ 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": r.URL.Query().Get("t"), + "token": strings.Replace(r.URL.Query().Get("t"), " ", "+", -1), } 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") { - renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.") + base["error"] = "New passwords are not identical. Please retry." + displayTmplError(w, http.StatusNotAcceptable, "reset.html", base) return } else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil { - 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.") + base["error"] = "The password you chose doesn't respect all constraints: " + err.Error() + displayTmplError(w, http.StatusNotAcceptable, "reset.html", base) return } @@ -70,22 +36,41 @@ func resetPassword(w http.ResponseWriter, r *http.Request) { conn, err := myLDAP.Connect() if err != nil || conn == nil { log.Println(err) - renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") + base["error"] = err.Error() + displayTmplError(w, http.StatusInternalServerError, "reset.html", base) return } - // Bind as service to perform the password change + // Bind as service to perform the search err = conn.ServiceBind() if err != nil { log.Println(err) - renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") + 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) return } // Replace the password by the new given if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil { log.Println(err) - renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") + base["error"] = err.Error() + displayTmplError(w, http.StatusInternalServerError, "reset.html", base) return } diff --git a/static.go b/static.go index 4946083..0570d91 100644 --- a/static.go +++ b/static.go @@ -7,19 +7,6 @@ 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 424b171..019e7f5 100644 --- a/static/change.html +++ b/static/change.html @@ -3,7 +3,6 @@
{{if .error}}{{end}} -
@@ -40,9 +39,6 @@ -
- -
Forgot your password?
diff --git a/static/header.html b/static/header.html index f86b55b..ba37563 100644 --- a/static/header.html +++ b/static/header.html @@ -10,7 +10,6 @@ nemunai.re password change -
diff --git a/static/login.html b/static/login.html index ec0678a..b8c9366 100644 --- a/static/login.html +++ b/static/login.html @@ -9,9 +9,6 @@
-
- -
Forgot your password? diff --git a/static/lost.html b/static/lost.html index bba8878..7b6e2e7 100644 --- a/static/lost.html +++ b/static/lost.html @@ -3,13 +3,9 @@
{{if .error}}{{end}} -
-
- -
Just want to change your password?
diff --git a/static/reset.html b/static/reset.html index 382ab00..dac37d7 100644 --- a/static/reset.html +++ b/static/reset.html @@ -1,9 +1,8 @@ {{template "header"}}

Forgot your password? Define a new one!

-
+ {{if .error}}{{end}} -
@@ -15,9 +14,6 @@
-
- -
{{template "footer"}}