Compare commits
20 commits
2cf22d678a
...
c98fe735ad
| Author | SHA1 | Date | |
|---|---|---|---|
| c98fe735ad | |||
| 1e1888625d | |||
| 7b0f3bc61d | |||
| 9870fa7831 | |||
| 78c4e9c3b0 | |||
| 5451ec3918 | |||
| 7b568607a6 | |||
| 2a9eec233a | |||
| 93673510d8 | |||
| 57775bbf89 | |||
| a2f368eb02 | |||
| 10f41e4ef8 | |||
| 121770c18a | |||
| 4b7405fc61 | |||
| 000f04a8f6 | |||
| 399e8b6367 | |||
| 6836e70e83 | |||
| 0197446952 | |||
| ee1f8ce69f | |||
| 65d0d4a53e |
18 changed files with 612 additions and 117 deletions
101
addy.go
101
addy.go
|
|
@ -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
27
altcha.go
Normal 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)
|
||||||
|
}
|
||||||
68
change.go
68
change.go
|
|
@ -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
40
csrf.go
Normal 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
12
go.mod
|
|
@ -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
43
go.sum
|
|
@ -9,8 +9,19 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/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=
|
||||||
|
|
|
||||||
5
ldap.go
5
ldap.go
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
42
login.go
42
login.go
|
|
@ -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
155
lost.go
|
|
@ -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
81
main.go
|
|
@ -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
63
ratelimit.go
Normal 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
|
||||||
|
}
|
||||||
73
reset.go
73
reset.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
13
static.go
13
static.go
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"}}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue