diff --git a/addy.go b/addy.go index fcb76d4..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" @@ -66,25 +66,37 @@ func checkAddyApiAuthorization(authorization []byte) *string { return &username } -func addyAliasAPI(w http.ResponseWriter, r *http.Request) { +func addyAliasAPIAuth(r *http.Request) (*string, error) { // Check authorization header fields := strings.Fields(r.Header.Get("Authorization")) if len(fields) != 2 || fields[0] != "Bearer" { - http.Error(w, "Authorization header should be a valid Bearer token", http.StatusUnauthorized) - return + return nil, fmt.Errorf("Authorization header should be a valid Bearer token") } // Decode header authorization, err := base32.StdEncoding.DecodeString(fields[1]) if err != nil { - log.Println("Invalid Authorization header: %s", err.Error()) - http.Error(w, "Authorization header should be a valid Bearer token", http.StatusUnauthorized) - return + log.Printf("Invalid Authorization header: %s", err.Error()) + return nil, err } user := checkAddyApiAuthorization(authorization) if user == nil { - http.Error(w, "Not authorized", http.StatusUnauthorized) + return nil, fmt.Errorf("Not authorized") + } + + return user, nil +} + +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) return } @@ -117,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) } @@ -154,11 +183,61 @@ 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) + return + } + + email := r.PathValue("alias") + + conn, err := myLDAP.Connect() + if err != nil || conn == nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = conn.ServiceBind() + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + dn, err := conn.SearchDN(*user, true) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = conn.DelMailAlias(dn, email) + if err != nil { + log.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Printf("Alias deleted for %s: %s", dn, email) + http.Error(w, "", http.StatusOK) +} + 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 0cddef4..eb358eb 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,21 @@ module git.nemunai.re/chldapasswd -go 1.22 +go 1.23.0 + +toolchain go1.26.0 require ( github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 - github.com/go-ldap/ldap/v3 v3.4.8 + github.com/go-ldap/ldap/v3 v3.4.12 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect - github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/google/uuid v1.6.0 // indirect - golang.org/x/crypto v0.21.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 94e9ed2..318fb83 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,19 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= +github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= +github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= +github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= +github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= +github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= @@ -30,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= @@ -44,11 +59,20 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -56,12 +80,19 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -69,24 +100,36 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 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 a702e07..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,25 +48,53 @@ 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()}) } else { + apiToken := AddyAPIToken(r.PostFormValue("login")) + cnt := "

To use our Addy.io compatible API, use the following token: " + AddyAPIToken(r.PostFormValue("login")) + "

")}) + 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"`) @@ -83,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 f4ccbe6..1e4c9d3 100644 --- a/lost.go +++ b/lost.go @@ -1,59 +1,114 @@ 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)) +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 +} - return base64.StdEncoding.EncodeToString(hash.Sum(nil)[:]) +func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) { + // Bind as service to perform the search + err := conn.ServiceBind() + if err != nil { + return "", "", err + } + + // Search the dn of the given user + dn, err := conn.SearchDN(login, true) + if err != nil { + return "", "", err + } + + // 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 } @@ -61,34 +116,24 @@ 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()}) - return - } - - // Bind as service to perform the search - err = conn.ServiceBind() - if err != nil { - log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()}) - return - } - - // Search the dn of the given user - dn, err := conn.SearchDN(r.PostFormValue("login"), true) - if err != 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 } // Generate the token - token := conn.genToken(dn, false) + 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) + return + } // Search the email address 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 } @@ -105,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:\nhttps://ldap.nemunai.re/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 != "" { @@ -122,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 { @@ -157,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 696b94e..36ab61d 100644 --- a/main.go +++ b/main.go @@ -17,11 +17,23 @@ 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 + var myLDAP = LDAP{ Host: "localhost", Port: 389, BaseDN: "dc=example,dc=com", MailPort: 587, + MailFrom: "noreply@nemunai.re", } type ResponseWriterPrefix struct { @@ -68,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 != "/" { @@ -139,17 +159,71 @@ 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) { + case "generate-lost-password-link": + if flag.NArg() != 2 { + log.Fatal("Need a second argument: email of the user to reset") + } + + login := flag.Arg(1) + + conn, err := myLDAP.Connect() + if err != nil || conn == nil { + log.Fatalf("Unable to connect to LDAP: %s", err.Error()) + } + + token, dn, err := lostPasswordToken(conn, login) + if err != nil { + log.Fatal(err.Error()) + } + + fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, myPublicURL, login, token) + return + case "serve": + case "server": + break + default: + log.Fatalf("%q is not a valid command", flag.Arg(0)) + } + } // Prepare graceful shutdown interrupt := make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) // Register handlers - http.HandleFunc(fmt.Sprintf("%s/", *baseURL), changePassword) + 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) http.HandleFunc(fmt.Sprintf("%s/auth", *baseURL), httpBasicAuth) http.HandleFunc(fmt.Sprintf("%s/login", *baseURL), tryLogin) http.HandleFunc(fmt.Sprintf("%s/change", *baseURL), changePassword) @@ -157,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 @@
{{if .error}}{{end}} +
@@ -39,6 +40,9 @@ +
+ +
Forgot your password?
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 @@
{{if .error}}{{end}} +
+
+ +
Just want to change your password?
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!

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