-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat: add LDAP authentication module with JWT issuance #2074
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from 1 commit
d0b103a
b560a96
4bcc782
4894ffb
57ecc90
68a6d3f
9578e40
e324976
0077f56
466a46b
2ea5d3b
81699e5
73f8018
9d6318d
b3e3610
ec784f3
fed560a
be40c86
ea7bfe2
142eba5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
package ldapauth | ||
|
||
import ( | ||
// "crypto/tls" | ||
"fmt" | ||
"time" | ||
|
||
"github.com/go-ldap/ldap/v3" | ||
"github.com/golang-jwt/jwt/v4" | ||
) | ||
|
||
type Config struct { | ||
Addr string | ||
BaseDN string | ||
BindUserDN string // optional | ||
BindPassword string // optional | ||
JWTSecret string | ||
mundele2004 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
type dialerFunc func(addr string, opts ...ldap.DialOpt) (*ldap.Conn, error) | ||
|
||
|
||
type Authenticator struct { | ||
cfg Config | ||
dialFn dialerFunc | ||
} | ||
|
||
func New(cfg Config) *Authenticator { | ||
return &Authenticator{ | ||
cfg: cfg, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the config specific to the LDAP library being used ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Config struct here is not directly from the go-ldap/ldap/v3 library — it's a custom wrapper defined within this module to keep the Authenticator decoupled from external configs. This design allows flexibility — if we change or extend the backend (e.g. add StartTLS, change port or baseDN logic), we just update our config struct without impacting the rest of the app. // Config holds LDAP server connection details and base search parameters.
|
||
dialFn: ldap.DialURL, | ||
} | ||
} | ||
|
||
func (a *Authenticator) Authenticate(username, password string) (string, error) { | ||
// l, err := ldap.DialURL("ldap://" + a.cfg.Addr) | ||
l, err := a.dialFn("ldap://" + a.cfg.Addr) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you elaborate on what There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, was wondering what possible validations we could have for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l is the LDAP connection (*ldap.Conn) returned by the dialer. We should validate a.cfg.Addr for non-empty and proper host:port format using net.SplitHostPort. |
||
|
||
if err != nil { | ||
return "", fmt.Errorf("failed to connect LDAP: %w", err) | ||
} | ||
defer l.Close() | ||
|
||
// err = l.StartTLS(&tls.Config{InsecureSkipVerify: true}) | ||
// if err != nil { | ||
// return "", fmt.Errorf("TLS error: %w", err) | ||
// } | ||
mundele2004 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if a.cfg.BindUserDN != "" { | ||
if err := l.Bind(a.cfg.BindUserDN, a.cfg.BindPassword); err != nil { | ||
return "", fmt.Errorf("bind failed: %w", err) | ||
} | ||
} | ||
|
||
searchReq := ldap.NewSearchRequest( | ||
a.cfg.BaseDN, | ||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, | ||
fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username)), | ||
[]string{"dn"}, | ||
nil, | ||
) | ||
|
||
sr, err := l.Search(searchReq) | ||
if err != nil || len(sr.Entries) != 1 { | ||
return "", fmt.Errorf("user not found") | ||
} | ||
|
||
userDN := sr.Entries[0].DN | ||
|
||
if err := l.Bind(userDN, password); err != nil { | ||
return "", fmt.Errorf("invalid credentials") | ||
} | ||
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ | ||
"sub": username, | ||
"exp": time.Now().Add(1 * time.Hour).Unix(), | ||
}) | ||
|
||
signed, err := token.SignedString([]byte(a.cfg.JWTSecret)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should preferably be decomposed into smaller, testable sections |
||
if err != nil { | ||
return "", fmt.Errorf("token signing error: %w", err) | ||
} | ||
|
||
return signed, nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
package ldapauth | ||
|
||
import ( | ||
"errors" | ||
"testing" | ||
|
||
"github.com/go-ldap/ldap/v3" | ||
) | ||
|
||
func TestAuthenticate_InvalidUser(t *testing.T) { | ||
auth := &Authenticator{ | ||
cfg: Config{ | ||
Addr: "fakehost", | ||
BaseDN: "dc=example,dc=com", | ||
JWTSecret: "testsecret", | ||
}, | ||
dialFn: func(addr string, opts ...ldap.DialOpt) (*ldap.Conn, error) { | ||
return nil, errors.New("dial error") | ||
}, | ||
|
||
} | ||
|
||
_, err := auth.Authenticate("testuser", "testpass") | ||
if err == nil { | ||
t.Fatal("expected error for invalid dial") | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
package main | ||
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"log" | ||
"net/http" | ||
|
||
"ldap-auth-go/internal/ldapauth" | ||
) | ||
|
||
var authenticator *ldapauth.Authenticator | ||
|
||
func main() { | ||
config := ldapauth.Config{ | ||
Addr: "localhost:389", // change this to your LDAP server | ||
BaseDN: "dc=example,dc=com", // adjust as per your directory | ||
BindUserDN: "cn=admin,dc=example,dc=com", // service account (optional) | ||
BindPassword: "admin", // service password | ||
JWTSecret: "your-secret-key", // replace with strong key | ||
} | ||
|
||
authenticator = ldapauth.New(config) | ||
|
||
http.HandleFunc("/login", loginHandler) | ||
log.Println("Server running on http://localhost:8080") | ||
log.Fatal(http.ListenAndServe(":8080", nil)) | ||
} | ||
|
||
func loginHandler(w http.ResponseWriter, r *http.Request) { | ||
var creds struct { | ||
Username string `json:"username"` | ||
Password string `json:"password"` | ||
} | ||
|
||
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil { | ||
http.Error(w, "Invalid JSON", http.StatusBadRequest) | ||
return | ||
} | ||
|
||
token, err := authenticator.Authenticate(creds.Username, creds.Password) | ||
if err != nil { | ||
http.Error(w, fmt.Sprintf("Login failed: %v", err), http.StatusUnauthorized) | ||
return | ||
} | ||
|
||
// json.NewEncoder(w).Encode(map[string]string{ | ||
// "token": token, | ||
// }) | ||
if err := json.NewEncoder(w).Encode(map[string]string{ | ||
"token": token, | ||
}); err != nil { | ||
http.Error(w, "Failed to write response", http.StatusInternalServerError) | ||
return | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
dn: ou=users,dc=example,dc=com | ||
objectClass: organizationalUnit | ||
ou: users | ||
|
||
dn: uid=testuser,ou=users,dc=example,dc=com | ||
objectClass: inetOrgPerson | ||
uid: testuser | ||
sn: User | ||
cn: Test User | ||
userPassword: testpass |
Uh oh!
There was an error while loading. Please reload this page.