Compare commits

..

8 commits

Author SHA1 Message Date
2cf22d678a chore(deps): update dependency go to v1.26.0
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-10 21:01:49 +00:00
e62ac9f3d0 chore(deps): update dependency go to v1.25.5
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 17:02:05 +00:00
6c4b1ea9c0 chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.12
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-18 10:49:30 +00:00
30990e5892 chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.11
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-07 03:03:41 +00:00
c671d26205 Can launch the executable with arguments to get reset token
All checks were successful
continuous-integration/drone/push Build is passing
2025-01-06 18:37:22 +01:00
3ec3d2649f chore(deps): update module github.com/go-ldap/ldap/v3 to v3.4.10
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-26 23:07:53 +00:00
a9eae79414 Hide krbPrincipalKey
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-31 17:19:12 +02:00
daab7bf699 Can delete own aliases
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-31 17:18:04 +02:00
18 changed files with 98 additions and 460 deletions

41
addy.go
View file

@ -3,13 +3,13 @@ package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/json"
"flag"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"strings"
@ -76,7 +76,7 @@ func addyAliasAPIAuth(r *http.Request) (*string, error) {
// Decode header
authorization, err := base32.StdEncoding.DecodeString(fields[1])
if err != nil {
log.Printf("Invalid Authorization header: %s", err.Error())
log.Println("Invalid Authorization header: %s", err.Error())
return nil, err
}
@ -89,11 +89,6 @@ func addyAliasAPIAuth(r *http.Request) (*string, error) {
}
func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
if !aliasLimiter.Allow(remoteIP(r)) {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
user, err := addyAliasAPIAuth(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
@ -129,23 +124,6 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
return
}
// Validate domain against allowlist
if len(allowedAliasDomains) == 0 {
http.Error(w, "Alias creation is not configured", http.StatusServiceUnavailable)
return
}
domainAllowed := false
for _, d := range allowedAliasDomains {
if body.Domain == d {
domainAllowed = true
break
}
}
if !domainAllowed {
http.Error(w, "Domain not allowed", http.StatusBadRequest)
return
}
if len(body.Alias) == 0 {
body.Alias = generateRandomString(10)
}
@ -184,11 +162,6 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) {
}
func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) {
if !aliasLimiter.Allow(remoteIP(r)) {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
user, err := addyAliasAPIAuth(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
@ -230,14 +203,10 @@ func addyAliasAPIDelete(w http.ResponseWriter, r *http.Request) {
}
func generateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, length)
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
panic("crypto/rand unavailable: " + err.Error())
}
for i, b := range buf {
result[i] = charset[int(b)%len(charset)]
for i := range result {
result[i] = charset[rand.Intn(len(charset))]
}
return string(result)
}

View file

@ -1,27 +0,0 @@
package main
import (
"net/http"
goaltcha "github.com/k42-software/go-altcha"
altchahttp "github.com/k42-software/go-altcha/http"
)
func serveAltchaJS(w http.ResponseWriter, r *http.Request) {
altchahttp.ServeJavascript(w, r)
}
func serveAltchaChallenge(w http.ResponseWriter, r *http.Request) {
challenge := goaltcha.NewChallenge()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
_, _ = w.Write([]byte(challenge.Encode()))
}
func validateAltcha(r *http.Request) bool {
encoded := r.PostFormValue("altcha")
if encoded == "" {
return false
}
return goaltcha.ValidateResponse(encoded, true)
}

View file

@ -4,90 +4,46 @@ import (
"errors"
"log"
"net/http"
"unicode"
)
func checkPasswdConstraint(password string) error {
if len(password) < 12 {
return errors.New("too short, please choose a password at least 12 characters long")
}
var hasUpper, hasLower, hasDigit bool
for _, r := range password {
switch {
case unicode.IsUpper(r):
hasUpper = true
case unicode.IsLower(r):
hasLower = true
case unicode.IsDigit(r):
hasDigit = true
}
}
if !hasUpper || !hasLower || !hasDigit {
return errors.New("password must contain at least one uppercase letter, one lowercase letter, and one digit")
if len(password) < 8 {
return errors.New("too short, please choose a password at least 8 characters long.")
}
return nil
}
func changePassword(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && !changeLimiter.Allow(remoteIP(r)) {
csrfToken, _ := setCSRFToken(w)
displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]interface{}{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken})
return
}
if r.Method != "POST" {
csrfToken, err := setCSRFToken(w)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
displayTmpl(w, "change.html", map[string]interface{}{"csrf_token": csrfToken})
displayTmpl(w, "change.html", map[string]interface{}{})
return
}
if !validateCSRF(r) {
csrfToken, _ := setCSRFToken(w)
displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken})
return
}
if !validateAltcha(r) {
csrfToken, _ := setCSRFToken(w)
displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken})
return
}
renderError := func(status int, msg string) {
csrfToken, _ := setCSRFToken(w)
displayTmplError(w, status, "change.html", map[string]interface{}{"error": msg, "csrf_token": csrfToken})
}
// Check the two new passwords are identical
if r.PostFormValue("newpassword") != r.PostFormValue("new2password") {
renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.")
displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "New passwords are not identical. Please retry."})
} else if len(r.PostFormValue("login")) == 0 {
renderError(http.StatusNotAcceptable, "Please provide a valid login")
displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "Please provide a valid login"})
} else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil {
renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error())
displayTmplError(w, http.StatusNotAcceptable, "change.html", map[string]interface{}{"error": "The password you chose doesn't respect all constraints: " + err.Error()})
} else {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()})
} else if err := conn.ServiceBind(); err != nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()})
} else if dn, err := conn.SearchDN(r.PostFormValue("login"), true); err != nil {
log.Println(err)
renderError(http.StatusUnauthorized, "Invalid login or password.")
displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()})
} else if err := conn.Bind(dn, r.PostFormValue("password")); err != nil {
log.Println(err)
renderError(http.StatusUnauthorized, "Invalid login or password.")
displayTmplError(w, http.StatusUnauthorized, "change.html", map[string]interface{}{"error": err.Error()})
} else if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
displayTmplError(w, http.StatusInternalServerError, "change.html", map[string]interface{}{"error": err.Error()})
} else {
displayMsg(w, "Password successfully changed!", http.StatusOK)
}

40
csrf.go
View file

@ -1,40 +0,0 @@
package main
import (
"crypto/rand"
"encoding/base64"
"net/http"
)
func generateCSRFToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func setCSRFToken(w http.ResponseWriter) (string, error) {
token, err := generateCSRFToken()
if err != nil {
return "", err
}
http.SetCookie(w, &http.Cookie{
Name: "csrf_token",
Value: token,
Path: "/",
HttpOnly: false, // must be readable via form hidden field comparison
SameSite: http.SameSiteStrictMode,
Secure: !devMode,
})
return token, nil
}
func validateCSRF(r *http.Request) bool {
cookie, err := r.Cookie("csrf_token")
if err != nil || cookie.Value == "" {
return false
}
formToken := r.PostFormValue("csrf_token")
return formToken != "" && cookie.Value == formToken
}

2
go.mod
View file

@ -14,8 +14,6 @@ require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/k42-software/go-altcha v0.1.1
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/crypto v0.36.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
)

4
go.sum
View file

@ -41,10 +41,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/k42-software/go-altcha v0.1.1 h1:vfA+0+0gr7jK4vp21Q7xvEpIjDsx8PqzxS0obgIToQs=
github.com/k42-software/go-altcha v0.1.1/go.mod h1:2aX+0PkUSI0YPDVfjapZeuGELWt8ugEXkg8gr6QejMU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View file

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

View file

@ -2,11 +2,9 @@ package main
import (
"fmt"
"html"
"html/template"
"log"
"net/http"
"net/url"
"strings"
"github.com/go-ldap/ldap/v3"
@ -48,16 +46,6 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
return
}
if !authLimiter.Allow(remoteIP(r)) {
displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]interface{}{"error": "Too many login attempts. Please try again later."})
return
}
if !validateAltcha(r) {
displayTmplError(w, http.StatusForbidden, "login.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."})
return
}
if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil {
log.Println(err)
displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()})
@ -67,34 +55,20 @@ func tryLogin(w http.ResponseWriter, r *http.Request) {
cnt := "<ul>"
for _, e := range entries {
for i, v := range e.Values {
safeName := html.EscapeString(e.Name)
safeVal := html.EscapeString(v)
elemID := fmt.Sprintf("mailAlias-%d", i)
if e.Name == "userPassword" || e.Name == "krbPrincipalKey" {
cnt += "<li><strong>" + safeName + ":</strong> <em>[...]</em></li>"
cnt += "<li><strong>" + e.Name + ":</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>`
cnt += "<li id='" + fmt.Sprintf("mailAlias-%d", i) + "'><strong>" + e.Name + ":</strong> " + v + `<button type="button" class="mx-1 btn btn-sm btn-danger" onclick="fetch('/api/v1/aliases/` + v + `', {'method': 'delete', 'headers': {'Authorization': 'Bearer ` + apiToken + `'}}).then((res) => { if (res.ok) document.getElementById('` + fmt.Sprintf("mailAlias-%d", i) + `').remove(); });">Supprimer</a></li>`
} else {
cnt += "<li><strong>" + safeName + ":</strong> " + safeVal + "</li>"
cnt += "<li><strong>" + e.Name + ":</strong> " + v + "</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>" + html.EscapeString(apiToken) + "</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>" + apiToken + "</code></p>")})
}
}
func httpBasicAuth(w http.ResponseWriter, r *http.Request) {
if !authLimiter.Allow(remoteIP(r)) {
w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`)
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("Too many requests"))
return
}
if user, pass, ok := r.BasicAuth(); ok {
if entries, err := login(user, pass); err != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`)
@ -113,7 +87,7 @@ func httpBasicAuth(w http.ResponseWriter, r *http.Request) {
}
return
}
} else if dockerRegistrySecret != "" && r.Header.Get("X-Special-Auth") == dockerRegistrySecret {
} else if v := r.Header.Get("X-Special-Auth"); v == "docker-registry" {
method := r.Header.Get("X-Original-Method")
uri := r.Header.Get("X-Original-URI")

121
lost.go
View file

@ -1,64 +1,54 @@
package main
import (
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"encoding/binary"
"io"
"log"
"net/http"
"os"
"os/exec"
"sync"
"time"
"gopkg.in/gomail.v2"
)
type resetTokenEntry struct {
dn string
expiresAt time.Time
}
var resetTokenStore = struct {
mu sync.Mutex
tokens map[string]resetTokenEntry
}{tokens: make(map[string]resetTokenEntry)}
func generateResetToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
func (l LDAPConn) genToken(dn string, previous bool) string {
hour := time.Now()
// Generate the previous token?
if previous {
hour.Add(time.Hour * -1)
}
return base64.URLEncoding.EncodeToString(b), nil
}
func storeResetToken(token string, dn string) {
resetTokenStore.mu.Lock()
defer resetTokenStore.mu.Unlock()
b := make([]byte, binary.MaxVarintLen64)
binary.PutVarint(b, hour.Round(time.Hour).Unix())
// Clean expired tokens
now := time.Now()
for t, e := range resetTokenStore.tokens {
if now.After(e.expiresAt) {
delete(resetTokenStore.tokens, t)
// Search the email address and current password
entries, err := l.GetEntry(dn)
if err != nil {
log.Println("Unable to generate token:", err)
return "#err"
}
email := ""
curpasswd := ""
for _, e := range entries {
if e.Name == "mail" {
email += e.Values[0]
} else if e.Name == "userPassword" {
curpasswd += e.Values[0]
}
}
resetTokenStore.tokens[token] = resetTokenEntry{
dn: dn,
expiresAt: now.Add(time.Hour),
}
}
func consumeResetToken(token string) (string, bool) {
resetTokenStore.mu.Lock()
defer resetTokenStore.mu.Unlock()
entry, ok := resetTokenStore.tokens[token]
if !ok || time.Now().After(entry.expiresAt) {
delete(resetTokenStore.tokens, token)
return "", false
}
delete(resetTokenStore.tokens, token)
return entry.dn, true
// Hash that
hash := sha512.New()
hash.Write(b)
hash.Write([]byte(dn))
hash.Write([]byte(email))
hash.Write([]byte(curpasswd))
return base64.StdEncoding.EncodeToString(hash.Sum(nil)[:])
}
func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) {
@ -74,41 +64,15 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) {
return "", "", err
}
// Generate a cryptographically random token
token, err := generateResetToken()
if err != nil {
return "", "", err
}
// Store token server-side with expiration
storeResetToken(token, dn)
// Generate the token
token := conn.genToken(dn, false)
return token, dn, nil
}
func lostPassword(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && !lostLimiter.Allow(remoteIP(r)) {
displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]interface{}{"error": "Too many requests. Please try again later."})
return
}
if r.Method != "POST" {
csrfToken, err := setCSRFToken(w)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
displayTmpl(w, "lost.html", map[string]interface{}{"csrf_token": csrfToken})
return
}
if !validateCSRF(r) {
displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again."})
return
}
if !validateAltcha(r) {
displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."})
displayTmpl(w, "lost.html", map[string]interface{}{})
return
}
@ -116,7 +80,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to process your request. Please try again later."})
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()})
return
}
@ -124,8 +88,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
token, dn, err := lostPasswordToken(conn, r.PostFormValue("login"))
if err != nil {
log.Println(err)
// Return generic message to avoid user enumeration
displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()})
return
}
@ -133,7 +96,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
entries, err := conn.GetEntry(dn)
if err != nil {
log.Println(err)
displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": err.Error()})
return
}
@ -150,16 +113,16 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
if email == "" {
log.Println("Unable to find a valid adress for user " + dn)
displayMsg(w, "If an account with that login exists, a password recovery email has been sent.", http.StatusOK)
displayTmplError(w, http.StatusBadRequest, "lost.html", map[string]interface{}{"error": "We were unable to find a valid email address associated with your account. Please contact an administrator."})
return
}
// Send the email
m := gomail.NewMessage()
m.SetHeader("From", myLDAP.MailFrom)
m.SetHeader("From", "noreply@nemunai.re")
m.SetHeader("To", email)
m.SetHeader("Subject", "SSO nemunai.re: password recovery")
m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\n"+myPublicURL+"/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nThis link expires in 1 hour and can only be used once.\n\nBest regards,\n-- \nnemunai.re SSO")
m.SetBody("text/plain", "Hello "+cn+"!\n\nSomeone, and we hope it's you, requested to reset your account password. \nIn order to continue, go to:\n"+BASEURL+"/reset?l="+r.PostFormValue("login")+"&t="+token+"\n\nBest regards,\n-- \nnemunai.re SSO")
var s gomail.Sender
if myLDAP.MailHost != "" {
@ -167,7 +130,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
s, err = d.Dial()
if err != nil {
log.Println("Unable to connect to email server: " + err.Error())
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."})
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to connect to email server: " + err.Error()})
return
}
} else {
@ -202,7 +165,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) {
if err := gomail.Send(s, m); err != nil {
log.Println("Unable to send email: " + err.Error())
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."})
displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send email: " + err.Error()})
return
}

51
main.go
View file

@ -17,23 +17,13 @@ import (
"syscall"
)
var myPublicURL = "https://ldap.nemunai.re"
var devMode bool
// dockerRegistrySecret is required for X-Special-Auth anonymous access.
// If empty, the feature is disabled.
var dockerRegistrySecret string
// allowedAliasDomains is the allowlist of domains users may create aliases under.
// If empty, alias creation is disabled.
var allowedAliasDomains []string
const BASEURL = "https://ldap.nemunai.re"
var myLDAP = LDAP{
Host: "localhost",
Port: 389,
BaseDN: "dc=example,dc=com",
MailPort: 587,
MailFrom: "noreply@nemunai.re",
}
type ResponseWriterPrefix struct {
@ -80,16 +70,8 @@ func main() {
var bind = flag.String("bind", "127.0.0.1:8080", "Bind port/socket")
var baseURL = flag.String("baseurl", "/", "URL prepended to each URL")
var configfile = flag.String("config", "", "path to the configuration file")
var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails")
var dev = flag.Bool("dev", false, "Development mode: disables HSTS and cookie Secure flag for local HTTP testing")
flag.Parse()
myPublicURL = *publicURL
devMode = *dev
if devMode {
log.Println("WARNING: running in development mode — security features relaxed, do not use in production")
}
// Sanitize options
log.Println("Checking paths...")
if *baseURL != "/" {
@ -159,31 +141,9 @@ func main() {
if val, ok := os.LookupEnv("SMTP_USER"); ok {
myLDAP.MailUser = val
}
if val, ok := os.LookupEnv("SMTP_PASSWORD_FILE"); ok {
if fd, err := os.Open(val); err != nil {
log.Fatal(err)
} else if cnt, err := os.ReadFile(val); err != nil {
fd.Close()
log.Fatal(err)
} else {
fd.Close()
myLDAP.MailPassword = string(cnt)
}
} else if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok {
if val, ok := os.LookupEnv("SMTP_PASSWORD"); ok {
myLDAP.MailPassword = val
}
if val, ok := os.LookupEnv("SMTP_FROM"); ok {
myLDAP.MailFrom = val
}
if val, ok := os.LookupEnv("PUBLIC_URL"); ok {
myPublicURL = val
}
if val, ok := os.LookupEnv("DOCKER_REGISTRY_SECRET"); ok {
dockerRegistrySecret = val
}
if val, ok := os.LookupEnv("ALIAS_ALLOWED_DOMAINS"); ok && val != "" {
allowedAliasDomains = strings.Split(val, ",")
}
if flag.NArg() > 0 {
switch flag.Arg(0) {
@ -204,7 +164,7 @@ func main() {
log.Fatal(err.Error())
}
fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, myPublicURL, login, token)
fmt.Printf("Reset link for %s: %s/reset?l=%s&t=%s", dn, BASEURL, login, token)
return
case "serve":
case "server":
@ -219,8 +179,6 @@ func main() {
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
// Register handlers
http.HandleFunc(fmt.Sprintf("GET %s/altcha.min.js", *baseURL), serveAltchaJS)
http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", *baseURL), serveAltchaChallenge)
http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword)
http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI)
http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", *baseURL), addyAliasAPIDelete)
@ -231,8 +189,7 @@ func main() {
http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword)
srv := &http.Server{
Addr: *bind,
Handler: securityHeaders(http.DefaultServeMux),
Addr: *bind,
}
// Serve content

View file

@ -1,63 +0,0 @@
package main
import (
"net"
"net/http"
"sync"
"time"
)
type rateLimiter struct {
mu sync.Mutex
counts map[string][]time.Time
limit int
window time.Duration
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{
counts: make(map[string][]time.Time),
limit: limit,
window: window,
}
}
func (rl *rateLimiter) Allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
windowStart := now.Add(-rl.window)
timestamps := rl.counts[key]
filtered := timestamps[:0]
for _, t := range timestamps {
if t.After(windowStart) {
filtered = append(filtered, t)
}
}
if len(filtered) >= rl.limit {
rl.counts[key] = filtered
return false
}
rl.counts[key] = append(filtered, now)
return true
}
var (
authLimiter = newRateLimiter(20, time.Minute)
changeLimiter = newRateLimiter(10, time.Minute)
lostLimiter = newRateLimiter(5, time.Minute)
resetLimiter = newRateLimiter(10, time.Minute)
aliasLimiter = newRateLimiter(30, time.Minute)
)
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

View file

@ -3,66 +3,32 @@ package main
import (
"log"
"net/http"
"strings"
)
func resetPassword(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && !resetLimiter.Allow(remoteIP(r)) {
http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests)
return
}
if len(r.URL.Query().Get("l")) == 0 || len(r.URL.Query().Get("t")) == 0 {
http.Redirect(w, r, "lost", http.StatusFound)
return
}
base := map[string]interface{}{
"login": r.URL.Query().Get("l"),
"token": r.URL.Query().Get("t"),
"token": strings.Replace(r.URL.Query().Get("t"), " ", "+", -1),
}
if r.Method != "POST" {
csrfToken, err := setCSRFToken(w)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
base["csrf_token"] = csrfToken
displayTmpl(w, "reset.html", base)
return
}
renderError := func(status int, msg string) {
csrfToken, _ := setCSRFToken(w)
base["error"] = msg
base["csrf_token"] = csrfToken
displayTmplError(w, status, "reset.html", base)
}
if !validateCSRF(r) {
renderError(http.StatusForbidden, "Invalid or missing CSRF token. Please try again.")
return
}
if !validateAltcha(r) {
renderError(http.StatusForbidden, "Invalid or missing altcha response. Please try again.")
return
}
// Check the two new passwords are identical
if r.PostFormValue("newpassword") != r.PostFormValue("new2password") {
renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.")
base["error"] = "New passwords are not identical. Please retry."
displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
return
} else if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil {
renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error())
return
}
// Validate and consume the token (single-use, server-side)
token := r.PostFormValue("token")
dn, ok := consumeResetToken(token)
if !ok {
renderError(http.StatusNotAcceptable, "Token invalid or expired, please retry the lost password procedure. Tokens expire after 1 hour.")
base["error"] = "The password you chose doesn't respect all constraints: " + err.Error()
displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
return
}
@ -70,22 +36,41 @@ func resetPassword(w http.ResponseWriter, r *http.Request) {
conn, err := myLDAP.Connect()
if err != nil || conn == nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
base["error"] = err.Error()
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
return
}
// Bind as service to perform the password change
// Bind as service to perform the search
err = conn.ServiceBind()
if err != nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
base["error"] = err.Error()
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
return
}
// Search the dn of the given user
dn, err := conn.SearchDN(r.PostFormValue("login"), true)
if err != nil {
log.Println(err)
base["error"] = err.Error()
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
return
}
// Check token validity (allow current token + last one)
if conn.genToken(dn, false) != r.PostFormValue("token") && conn.genToken(dn, true) != r.PostFormValue("token") {
base["error"] = "Token invalid, please retry the lost password procedure. Please note that our token expires after 1 hour."
displayTmplError(w, http.StatusNotAcceptable, "reset.html", base)
return
}
// Replace the password by the new given
if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil {
log.Println(err)
renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.")
base["error"] = err.Error()
displayTmplError(w, http.StatusInternalServerError, "reset.html", base)
return
}

View file

@ -7,19 +7,6 @@ import (
"net/http"
)
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com")
if !devMode {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
//go:embed all:static
var assets embed.FS

View file

@ -3,7 +3,6 @@
<form method="post" action="change">
{{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">
<input name="login" required="" class="form-control" id="input_0" type="text" placeholder="Login" autofocus>
</div>
@ -40,9 +39,6 @@
</button>
</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>
<a href="/lost" class="btn btn-outline-secondary">Forgot your password?</a>
</form>

View file

@ -10,7 +10,6 @@
<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>
<script src="altcha.min.js" async defer></script>
</head>
<body>
<div class="container">

View file

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

View file

@ -3,13 +3,9 @@
<form method="post" action="lost">
{{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">
<input name="login" required="" class="form-control" id="input_0" type="text" placeholder="Login" autofocus>
</div>
<div class="form-group">
<altcha-widget challengeurl="altcha-challenge"></altcha-widget>
</div>
<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>
</form>

View file

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