All checks were successful
continuous-integration/drone/push Build is passing
Perform a dummy LDAP bind when SearchDN fails so that the response time is indistinguishable between unknown users and wrong passwords. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
218 lines
5.4 KiB
Go
218 lines
5.4 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/go-ldap/ldap/v3"
|
|
)
|
|
|
|
type profileField struct {
|
|
Name string
|
|
Label string
|
|
Value string
|
|
}
|
|
|
|
type profileAlias struct {
|
|
Value string
|
|
URLSafe string
|
|
ElemID string
|
|
Token string
|
|
}
|
|
|
|
var ldapLabels = map[string]string{
|
|
"cn": "Full name",
|
|
"uid": "Username",
|
|
"givenName": "First name",
|
|
"sn": "Last name",
|
|
"displayName": "Display name",
|
|
"telephoneNumber": "Phone",
|
|
"mobile": "Mobile",
|
|
"employeeNumber": "Employee ID",
|
|
"o": "Organization",
|
|
"ou": "Department",
|
|
"title": "Title",
|
|
"description": "Description",
|
|
"labeledURI": "Website",
|
|
}
|
|
|
|
// ldapSkip lists attributes that should never be shown to the user.
|
|
var ldapSkip = map[string]bool{
|
|
"userPassword": true,
|
|
"krbPrincipalKey": true,
|
|
"objectClass": true,
|
|
"entryUUID": true,
|
|
"entryDN": true,
|
|
"structuralObjectClass": true,
|
|
"hasSubordinates": true,
|
|
"krbExtraData": true,
|
|
}
|
|
|
|
// isGeneratedAlias returns true for auto-generated alias local parts:
|
|
// exactly 10 characters and containing at least one digit or uppercase letter,
|
|
// which distinguishes them from plain words like "postmaster" or "abonnement".
|
|
func isGeneratedAlias(local string) bool {
|
|
if len(local) != 10 {
|
|
return false
|
|
}
|
|
for _, c := range local {
|
|
if c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ldapLabel(name string) string {
|
|
if l, ok := ldapLabels[name]; ok {
|
|
return l
|
|
}
|
|
return name
|
|
}
|
|
|
|
func login(login string, password string) ([]*ldap.EntryAttribute, error) {
|
|
conn, err := myLDAP.Connect()
|
|
if err != nil || conn == nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err = conn.ServiceBind(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var dn string
|
|
dn, err = conn.SearchDN(login, true)
|
|
if err != nil {
|
|
dn, err = conn.SearchDN(login, false)
|
|
if err != nil {
|
|
// User not found: perform a dummy bind to prevent username enumeration via timing.
|
|
conn.Bind("cn=dummy,"+myLDAP.BaseDN, password)
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := conn.Bind(dn, password); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if entries, err := conn.GetEntry(dn); err != nil {
|
|
return nil, err
|
|
} else {
|
|
return entries, nil
|
|
}
|
|
}
|
|
|
|
func tryLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
displayTmpl(w, "login.html", map[string]any{})
|
|
return
|
|
}
|
|
|
|
if !authLimiter.Allow(remoteIP(r)) {
|
|
displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]any{"error": "Too many login attempts. Please try again later."})
|
|
return
|
|
}
|
|
|
|
if !validateAltcha(r) {
|
|
displayTmplError(w, http.StatusForbidden, "login.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."})
|
|
return
|
|
}
|
|
|
|
loginName := r.PostFormValue("login")
|
|
entries, err := login(loginName, r.PostFormValue("password"))
|
|
if err != nil {
|
|
log.Println(err)
|
|
displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
apiToken := AddyAPIToken(loginName)
|
|
var fields []profileField
|
|
var emails []string
|
|
var aliases []profileAlias
|
|
aliasIdx := 0
|
|
|
|
for _, e := range entries {
|
|
if ldapSkip[e.Name] {
|
|
continue
|
|
}
|
|
for _, v := range e.Values {
|
|
switch {
|
|
case e.Name == "mail":
|
|
emails = append(emails, v)
|
|
case e.Name == "mailAlias" && isGeneratedAlias(strings.SplitN(v, "@", 2)[0]):
|
|
elemID := fmt.Sprintf("alias-%d", aliasIdx)
|
|
aliasIdx++
|
|
aliases = append(aliases, profileAlias{
|
|
Value: v,
|
|
URLSafe: url.PathEscape(v),
|
|
ElemID: elemID,
|
|
Token: apiToken,
|
|
})
|
|
case e.Name == "mailAlias":
|
|
emails = append(emails, v)
|
|
default:
|
|
fields = append(fields, profileField{
|
|
Name: e.Name,
|
|
Label: ldapLabel(e.Name),
|
|
Value: v,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
displayTmpl(w, "profile.html", map[string]any{
|
|
"login": loginName,
|
|
"fields": fields,
|
|
"emails": emails,
|
|
"aliases": aliases,
|
|
"api_token": apiToken,
|
|
"card_wide": true,
|
|
})
|
|
}
|
|
|
|
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"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte(err.Error()))
|
|
return
|
|
} else {
|
|
w.Header().Set("X-Remote-User", user)
|
|
w.WriteHeader(http.StatusOK)
|
|
for _, e := range entries {
|
|
for _, v := range e.Values {
|
|
if e.Name != "userPassword" {
|
|
fmt.Fprintf(w, "%s: %s", e.Name, v)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
} else if dockerRegistrySecret != "" && r.Header.Get("X-Special-Auth") == dockerRegistrySecret {
|
|
method := r.Header.Get("X-Original-Method")
|
|
uri := r.Header.Get("X-Original-URI")
|
|
|
|
if (method == "GET" || method == "HEAD") && uri != "" && uri != "/" && uri != "/v2/" && !strings.HasPrefix(uri, "/v2/_") {
|
|
log.Printf("docker-registry: Permit anonymous login for URL %s", uri)
|
|
w.Header().Set("X-Remote-User", "anonymous")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="nemunai.re restricted"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
w.Write([]byte("Please login"))
|
|
}
|