Compare commits

...

20 commits

Author SHA1 Message Date
c98fe735ad feat: add -dev flag for local HTTP testing
All checks were successful
continuous-integration/drone/push Build is passing
In development mode (-dev):
- HSTS header is omitted (prevents browser caching HTTPS-only requirement)
- CSRF cookie Secure flag is cleared (allows cookies over plain HTTP)
- A warning is logged on startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
1e1888625d feat(security): add altcha proof-of-work CAPTCHA to all sensitive forms
Integrate go-altcha to protect login, change password, lost password,
and reset password forms against automated submissions. Serves the
altcha widget JS from the embedded library, exposes a challenge
endpoint, validates responses server-side with replay prevention, and
updates the CSP to allow self-hosted scripts and WebAssembly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
7b0f3bc61d fix(security): strengthen password policy
Increase minimum password length from 8 to 12 characters and require
at least one uppercase letter, one lowercase letter, and one digit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
9870fa7831 fix(security): use crypto/rand for alias prefix generation
Replace math/rand.Intn with crypto/rand for generating random alias
prefixes. While aliases are not security tokens, using a CSPRNG ensures
consistent use of cryptographically secure randomness throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
78c4e9c3b0 fix(security): enforce domain allowlist for email alias creation
Add ALIAS_ALLOWED_DOMAINS env var (comma-separated) that restricts which
domains users may create aliases under. Alias creation is disabled when
the env var is not set. Prevents users from creating aliases with arbitrary
domains (e.g. for phishing/spoofing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
5451ec3918 fix(security): add HTTP security headers middleware
Set X-Frame-Options, X-Content-Type-Options, Referrer-Policy, CSP,
and Strict-Transport-Security on all responses to mitigate clickjacking,
MIME sniffing, XSS, and downgrade attacks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
7b568607a6 fix(security): require configurable secret for X-Special-Auth docker registry bypass
Replace hardcoded "docker-registry" check with a configurable secret via
DOCKER_REGISTRY_SECRET env var. When the env var is unset, the anonymous
docker registry bypass is disabled entirely, closing the unauthenticated
access path if the service is accidentally exposed directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
2a9eec233a fix(security): add per-IP rate limiting to all authentication endpoints
Implement sliding window rate limiter to prevent brute-force attacks:
- /auth and /login: 20 requests/minute per IP
- /change: 10 POST requests/minute per IP
- /lost: 5 POST requests/minute per IP (prevents email spam and user enumeration)
- /reset: 10 POST requests/minute per IP
- /api/v1/aliases: 30 requests/minute per IP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
93673510d8 fix(security): escape LDAP attribute data in HTML output to prevent XSS (CWE-79)
Use html.EscapeString for attribute names and values when building HTML.
Move dynamic data (alias URL, API token) to data-* attributes and use
a self-contained onclick function to read them, eliminating JS string
injection via LDAP-controlled values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
57775bbf89 fix(security): redesign password reset tokens using crypto/rand with server-side storage
- Replace SHA512-based deterministic token with 32-byte crypto/rand token
- Store tokens server-side with 1-hour expiry and single-use semantics
- Remove genToken (previously broken due to time.Add immutability bug)
- Add CSRF double-submit cookie protection to change/lost/reset forms
- Remove token from form action URL (use hidden fields only, POST body)
- Add MailFrom field and SMTP_FROM env var for configurable sender address
- Add SMTP_PASSWORD_FILE env var for secure SMTP password loading
- Add PUBLIC_URL env var and --public-url flag for configurable reset link domain
- Use generic error messages in handlers to avoid information disclosure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
a2f368eb02 fix(security): add missing return after redirect in resetPassword handler
http.Redirect only sets response headers; without return, handler execution
continued with empty login/token strings, potentially causing unexpected
LDAP queries and information leakage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
10f41e4ef8 fix(security): escape LDAP filter inputs to prevent filter injection (CWE-90)
Use ldap.EscapeFilter() on all user-controlled inputs before interpolating
them into LDAP search filter strings in SearchDN and SearchMailAlias.
Prevents authentication bypass via filter manipulation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:30:48 +07:00
121770c18a chore(deps): update dependency go to v1.26.0 2026-03-06 15:30:48 +07:00
4b7405fc61 chore(deps): update dependency go to v1.25.5 2026-03-06 15:30:48 +07:00
000f04a8f6 chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.12 2026-03-06 15:30:48 +07:00
399e8b6367 chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.11 2026-03-06 15:30:48 +07:00
6836e70e83 Can launch the executable with arguments to get reset token 2026-03-06 15:30:48 +07:00
0197446952 chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.10 2026-03-06 15:30:48 +07:00
ee1f8ce69f Hide krbPrincipalKey 2026-03-06 15:30:48 +07:00
65d0d4a53e Can delete own aliases 2026-03-06 15:30:48 +07:00
18 changed files with 612 additions and 117 deletions

101
addy.go
View file

@ -3,13 +3,13 @@ package main
import ( import (
"bytes" "bytes"
"crypto/hmac" "crypto/hmac"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base32" "encoding/base32"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"math/rand"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@ -66,25 +66,37 @@ func checkAddyApiAuthorization(authorization []byte) *string {
return &username return &username
} }
func addyAliasAPI(w http.ResponseWriter, r *http.Request) { func addyAliasAPIAuth(r *http.Request) (*string, error) {
// Check authorization header // Check authorization header
fields := strings.Fields(r.Header.Get("Authorization")) fields := strings.Fields(r.Header.Get("Authorization"))
if len(fields) != 2 || fields[0] != "Bearer" { if len(fields) != 2 || fields[0] != "Bearer" {
http.Error(w, "Authorization header should be a valid Bearer token", http.StatusUnauthorized) return nil, fmt.Errorf("Authorization header should be a valid Bearer token")
return
} }
// Decode header // Decode header
authorization, err := base32.StdEncoding.DecodeString(fields[1]) authorization, err := base32.StdEncoding.DecodeString(fields[1])
if err != nil { if err != nil {
log.Println("Invalid Authorization header: %s", err.Error()) log.Printf("Invalid Authorization header: %s", err.Error())
http.Error(w, "Authorization header should be a valid Bearer token", http.StatusUnauthorized) return nil, err
return
} }
user := checkAddyApiAuthorization(authorization) user := checkAddyApiAuthorization(authorization)
if user == nil { 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 return
} }
@ -117,6 +129,23 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
return 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 { if len(body.Alias) == 0 {
body.Alias = generateRandomString(10) 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 { func generateRandomString(length int) string {
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
result := make([]byte, length) result := make([]byte, length)
for i := range result { buf := make([]byte, length)
result[i] = charset[rand.Intn(len(charset))] 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) return string(result)
} }

27
altcha.go Normal file
View file

@ -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)
}

View file

@ -4,46 +4,90 @@ import (
"errors" "errors"
"log" "log"
"net/http" "net/http"
"unicode"
) )
func checkPasswdConstraint(password string) error { func checkPasswdConstraint(password string) error {
if len(password) < 8 { if len(password) < 12 {
return errors.New("too short, please choose a password at least 8 characters long.") 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 return nil
} }
func changePassword(w http.ResponseWriter, r *http.Request) { func changePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method == "POST" && !changeLimiter.Allow(remoteIP(r)) {
displayTmpl(w, "change.html", map[string]interface{}{}) csrfToken, _ := setCSRFToken(w)
displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]interface{}{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken})
return 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 // Check the two new passwords are identical
if r.PostFormValue("newpassword") != r.PostFormValue("new2password") { 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 { } 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 { } 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 { } else {
conn, err := myLDAP.Connect() conn, err := myLDAP.Connect()
if err != nil || conn == nil { if err != nil || conn == nil {
log.Println(err) 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 { } else if err := conn.ServiceBind(); err != nil {
log.Println(err) 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 { } else if dn, err := conn.SearchDN(r.PostFormValue("login"), true); err != nil {
log.Println(err) 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 { } else if err := conn.Bind(dn, r.PostFormValue("password")); err != nil {
log.Println(err) 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 { } else if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil {
log.Println(err) 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 { } else {
displayMsg(w, "Password successfully changed!", http.StatusOK) displayMsg(w, "Password successfully changed!", http.StatusOK)
} }

40
csrf.go Normal file
View file

@ -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
}

12
go.mod
View file

@ -1,17 +1,21 @@
module git.nemunai.re/chldapasswd module git.nemunai.re/chldapasswd
go 1.22 go 1.23.0
toolchain go1.26.0
require ( require (
github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 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 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
) )
require ( require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect 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 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 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
) )

43
go.sum
View file

@ -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/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 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.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 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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= 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/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 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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= 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-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.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.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.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 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 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.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.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-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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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.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.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.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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= 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.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-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.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.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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.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.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.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-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.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.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.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.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.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.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.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.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.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.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.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-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.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.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.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= 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 h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=

View file

@ -23,6 +23,7 @@ type LDAP struct {
MailPort int MailPort int
MailUser string MailUser string
MailPassword string MailPassword string
MailFrom string
} }
func (l LDAP) Connect() (*LDAPConn, error) { func (l LDAP) Connect() (*LDAPConn, error) {
@ -74,7 +75,7 @@ func (l LDAPConn) SearchDN(username string, person bool) (string, error) {
searchRequest := ldap.NewSearchRequest( searchRequest := ldap.NewSearchRequest(
l.BaseDN, l.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, 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"}, []string{"dn"},
nil, nil,
) )
@ -147,7 +148,7 @@ func (l LDAPConn) SearchMailAlias(address string) (int, error) {
searchRequest := ldap.NewSearchRequest( searchRequest := ldap.NewSearchRequest(
l.BaseDN, l.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectClass=*)(mailAlias=%s))", address), fmt.Sprintf("(&(objectClass=*)(mailAlias=%s))", ldap.EscapeFilter(address)),
[]string{"dn"}, []string{"dn"},
nil, nil,
) )

View file

@ -2,9 +2,11 @@ package main
import ( import (
"fmt" "fmt"
"html"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
@ -46,25 +48,53 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
return 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 { if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil {
log.Println(err) log.Println(err)
displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()}) displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()})
} else { } else {
apiToken := AddyAPIToken(r.PostFormValue("login"))
cnt := "<ul>" cnt := "<ul>"
for _, e := range entries { for _, e := range entries {
for _, v := range e.Values { for i, v := range e.Values {
if e.Name == "userPassword" { safeName := html.EscapeString(e.Name)
cnt += "<li><strong>" + e.Name + ":</strong> <em>[...]</em></li>" safeVal := html.EscapeString(v)
elemID := fmt.Sprintf("mailAlias-%d", i)
if e.Name == "userPassword" || e.Name == "krbPrincipalKey" {
cnt += "<li><strong>" + safeName + ":</strong> <em>[...]</em></li>"
} else if e.Name == "mailAlias" && len(strings.SplitN(v, "@", 2)[0]) == 10 {
safeURL := url.PathEscape(v)
safeToken := html.EscapeString(apiToken)
safeElemID := html.EscapeString(elemID)
cnt += `<li id="` + safeElemID + `"><strong>` + safeName + `:</strong> ` + safeVal +
`<button type="button" class="mx-1 btn btn-sm btn-danger" data-alias="` + safeURL + `" data-token="` + safeToken + `" data-elem="` + safeElemID + `" onclick="(function(b){fetch('/api/v1/aliases/'+b.dataset.alias,{'method':'delete','headers':{'Authorization':'Bearer '+b.dataset.token}}).then(function(r){if(r.ok)document.getElementById(b.dataset.elem).remove();})})(this)">Supprimer</button></li>`
} else { } else {
cnt += "<li><strong>" + e.Name + ":</strong> " + v + "</li>" cnt += "<li><strong>" + safeName + ":</strong> " + safeVal + "</li>"
} }
} }
} }
displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok<br><br>Here are the information we have about you:` + cnt + "</ul><p>To use our Addy.io compatible API, use the following token: <code>" + AddyAPIToken(r.PostFormValue("login")) + "</code></p>")}) displayTmpl(w, "message.html", map[string]interface{}{"details": template.HTML(`Login ok<br><br>Here are the information we have about you:` + cnt + "</ul><p>To use our Addy.io compatible API, use the following token: <code>" + html.EscapeString(apiToken) + "</code></p>")})
} }
} }
func httpBasicAuth(w http.ResponseWriter, r *http.Request) { 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 user, pass, ok := r.BasicAuth(); ok {
if entries, err := login(user, pass); err != nil { if entries, err := login(user, pass); err != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`) w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`)
@ -83,7 +113,7 @@ func httpBasicAuth(w http.ResponseWriter, r *http.Request) {
} }
return 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") method := r.Header.Get("X-Original-Method")
uri := r.Header.Get("X-Original-URI") uri := r.Header.Get("X-Original-URI")

155
lost.go
View file

@ -1,59 +1,114 @@
package main package main
import ( import (
"crypto/sha512" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/binary"
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"sync"
"time" "time"
"gopkg.in/gomail.v2" "gopkg.in/gomail.v2"
) )
func (l LDAPConn) genToken(dn string, previous bool) string { type resetTokenEntry struct {
hour := time.Now() dn string
// Generate the previous token? expiresAt time.Time
if previous { }
hour.Add(time.Hour * -1)
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) func storeResetToken(token string, dn string) {
binary.PutVarint(b, hour.Round(time.Hour).Unix()) resetTokenStore.mu.Lock()
defer resetTokenStore.mu.Unlock()
// Search the email address and current password // Clean expired tokens
entries, err := l.GetEntry(dn) now := time.Now()
if err != nil { for t, e := range resetTokenStore.tokens {
log.Println("Unable to generate token:", err) if now.After(e.expiresAt) {
return "#err" delete(resetTokenStore.tokens, t)
}
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),
}
}
// Hash that func consumeResetToken(token string) (string, bool) {
hash := sha512.New() resetTokenStore.mu.Lock()
hash.Write(b) defer resetTokenStore.mu.Unlock()
hash.Write([]byte(dn)) entry, ok := resetTokenStore.tokens[token]
hash.Write([]byte(email)) if !ok || time.Now().After(entry.expiresAt) {
hash.Write([]byte(curpasswd)) 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) { 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" { 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 return
} }
@ -61,34 +116,24 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
conn, err := myLDAP.Connect() conn, err := myLDAP.Connect()
if err != nil || conn == nil { if err != nil || conn == nil {
log.Println(err) 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
}
// 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()})
return return
} }
// Generate the token // 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 // Search the email address
entries, err := conn.GetEntry(dn) entries, err := conn.GetEntry(dn)
if err != nil { if err != nil {
log.Println(err) 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 return
} }
@ -105,16 +150,16 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
if email == "" { if email == "" {
log.Println("Unable to find a valid adress for user " + dn) 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 return
} }
// Send the email // Send the email
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", "noreply@nemunai.re") m.SetHeader("From", myLDAP.MailFrom)
m.SetHeader("To", email) m.SetHeader("To", email)
m.SetHeader("Subject", "SSO nemunai.re: password recovery") 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 var s gomail.Sender
if myLDAP.MailHost != "" { if myLDAP.MailHost != "" {
@ -122,7 +167,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
s, err = d.Dial() s, err = d.Dial()
if err != nil { if err != nil {
log.Println("Unable to connect to email server: " + err.Error()) 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 return
} }
} else { } else {
@ -157,7 +202,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
if err := gomail.Send(s, m); err != nil { if err := gomail.Send(s, m); err != nil {
log.Println("Unable to send email: " + err.Error()) 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 return
} }

81
main.go
View file

@ -17,11 +17,23 @@ import (
"syscall" "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{ var myLDAP = LDAP{
Host: "localhost", Host: "localhost",
Port: 389, Port: 389,
BaseDN: "dc=example,dc=com", BaseDN: "dc=example,dc=com",
MailPort: 587, MailPort: 587,
MailFrom: "noreply@nemunai.re",
} }
type ResponseWriterPrefix struct { type ResponseWriterPrefix struct {
@ -68,8 +80,16 @@ func main() {
var bind = flag.String("bind", "127.0.0.1:8080", "Bind port/socket") var bind = flag.String("bind", "127.0.0.1:8080", "Bind port/socket")
var baseURL = flag.String("baseurl", "/", "URL prepended to each URL") var baseURL = flag.String("baseurl", "/", "URL prepended to each URL")
var configfile = flag.String("config", "", "path to the configuration file") 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() 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 // Sanitize options
log.Println("Checking paths...") log.Println("Checking paths...")
if *baseURL != "/" { if *baseURL != "/" {
@ -139,17 +159,71 @@ func main() {
if val, ok := os.LookupEnv("SMTP_USER"); ok { if val, ok := os.LookupEnv("SMTP_USER"); ok {
myLDAP.MailUser = val 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 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 // Prepare graceful shutdown
interrupt := make(chan os.Signal, 1) interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
// Register handlers // 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("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/auth", *baseURL), httpBasicAuth)
http.HandleFunc(fmt.Sprintf("%s/login", *baseURL), tryLogin) http.HandleFunc(fmt.Sprintf("%s/login", *baseURL), tryLogin)
http.HandleFunc(fmt.Sprintf("%s/change", *baseURL), changePassword) http.HandleFunc(fmt.Sprintf("%s/change", *baseURL), changePassword)
@ -157,7 +231,8 @@ func main() {
http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword) http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword)
srv := &http.Server{ srv := &http.Server{
Addr: *bind, Addr: *bind,
Handler: securityHeaders(http.DefaultServeMux),
} }
// Serve content // Serve content

63
ratelimit.go Normal file
View file

@ -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
}

View file

@ -3,32 +3,66 @@ package main
import ( import (
"log" "log"
"net/http" "net/http"
"strings"
) )
func resetPassword(w http.ResponseWriter, r *http.Request) { 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 { if len(r.URL.Query().Get("l")) == 0 || len(r.URL.Query().Get("t")) == 0 {
http.Redirect(w, r, "lost", http.StatusFound) http.Redirect(w, r, "lost", http.StatusFound)
return
} }
base := map[string]interface{}{ base := map[string]interface{}{
"login": r.URL.Query().Get("l"), "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" { 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) displayTmpl(w, "reset.html", base)
return 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 // Check the two new passwords are identical
if r.PostFormValue("newpassword") != r.PostFormValue("new2password") { if r.PostFormValue("newpassword") != r.PostFormValue("new2password") {
base["error"] = "New passwords are not identical. Please retry." renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.")
displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
return return
} else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil { } else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil {
base["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())
displayTmplError(w, http.StatusNotAcceptable, "reset.html", base) 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 return
} }
@ -36,41 +70,22 @@ func resetPassword(w http.ResponseWriter, r *http.Request) {
conn, err := myLDAP.Connect() conn, err := myLDAP.Connect()
if err != nil || conn == nil { if err != nil || conn == nil {
log.Println(err) log.Println(err)
base["error"] = err.Error() renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
return return
} }
// Bind as service to perform the search // Bind as service to perform the password change
err = conn.ServiceBind() err = conn.ServiceBind()
if err != nil { if err != nil {
log.Println(err) log.Println(err)
base["error"] = err.Error() renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
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 return
} }
// Replace the password by the new given // Replace the password by the new given
if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil { if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil {
log.Println(err) log.Println(err)
base["error"] = err.Error() renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
return return
} }

View file

@ -7,6 +7,19 @@ import (
"net/http" "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 //go:embed all:static
var assets embed.FS var assets embed.FS

View file

@ -3,6 +3,7 @@
<form method="post" action="change"> <form method="post" action="change">
{{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}} {{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}}
<input type="hidden" name="csrf_token" value="{{ .csrf_token }}">
<div class="form-group"> <div class="form-group">
<input name="login" required="" class="form-control" id="input_0" type="text" placeholder="Login" autofocus> <input name="login" required="" class="form-control" id="input_0" type="text" placeholder="Login" autofocus>
</div> </div>
@ -39,6 +40,9 @@
</button> </button>
</div> </div>
</div> </div>
<div class="form-group">
<altcha-widget challengeurl="altcha-challenge"></altcha-widget>
</div>
<button class="btn btn-primary" type="submit">Change my password</button> <button class="btn btn-primary" type="submit">Change my password</button>
<a href="/lost" class="btn btn-outline-secondary">Forgot your password?</a> <a href="/lost" class="btn btn-outline-secondary">Forgot your password?</a>
</form> </form>

View file

@ -10,6 +10,7 @@
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<title>nemunai.re password change</title> <title>nemunai.re password change</title>
<script src="altcha.min.js" async defer></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">

View file

@ -9,6 +9,9 @@
<div class="form-group"> <div class="form-group">
<input name="password" required="" class="form-control" id="input_1" type="password" placeholder="Current password"> <input name="password" required="" class="form-control" id="input_1" type="password" placeholder="Current password">
</div> </div>
<div class="form-group">
<altcha-widget challengeurl="altcha-challenge"></altcha-widget>
</div>
<button class="btn btn-primary" type="submit">Sign in</button> <button class="btn btn-primary" type="submit">Sign in</button>
<a href="/lost" class="btn btn-outline-secondary">Forgot your password?</a> <a href="/lost" class="btn btn-outline-secondary">Forgot your password?</a>
</form> </form>

View file

@ -3,9 +3,13 @@
<form method="post" action="lost"> <form method="post" action="lost">
{{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}} {{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}}
<input type="hidden" name="csrf_token" value="{{ .csrf_token }}">
<div class="form-group"> <div class="form-group">
<input name="login" required="" class="form-control" id="input_0" type="text" placeholder="Login" autofocus> <input name="login" required="" class="form-control" id="input_0" type="text" placeholder="Login" autofocus>
</div> </div>
<div class="form-group">
<altcha-widget challengeurl="altcha-challenge"></altcha-widget>
</div>
<button class="btn btn-primary" type="submit">Reset my password</button> <button class="btn btn-primary" type="submit">Reset my password</button>
<a href="/change" class="btn btn-outline-success">Just want to change your password?</a> <a href="/change" class="btn btn-outline-success">Just want to change your password?</a>
</form> </form>

View file

@ -1,8 +1,9 @@
{{template "header"}} {{template "header"}}
<h1 class="display-4">Forgot your password? <small class="text-muted">Define a new one!</small></h1> <h1 class="display-4">Forgot your password? <small class="text-muted">Define a new one!</small></h1>
<form method="post" action="reset?l={{ .login }}&t={{ .token }}"> <form method="post" action="reset">
{{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}} {{if .error}}<div class="alert alert-danger" role="alert">{{.error}}</div>{{end}}
<input type="hidden" name="csrf_token" value="{{ .csrf_token }}">
<div class="form-group"> <div class="form-group">
<input required="" class="form-control" id="input_0" type="text" placeholder="Email" value="{{ .login }}" disabled=""> <input required="" class="form-control" id="input_0" type="text" placeholder="Email" value="{{ .login }}" disabled="">
</div> </div>
@ -14,6 +15,9 @@
<div class="form-group"> <div class="form-group">
<input name="new2password" required="" class="form-control" id="input_3" type="password" placeholder="Retype new password"> <input name="new2password" required="" class="form-control" id="input_3" type="password" placeholder="Retype new password">
</div> </div>
<div class="form-group">
<altcha-widget challengeurl="altcha-challenge"></altcha-widget>
</div>
<button class="btn btn-primary" type="submit">Reset my password</button> <button class="btn btn-primary" type="submit">Reset my password</button>
</form> </form>
{{template "footer"}} {{template "footer"}}