2024-10-28 14:58:11 +01:00
package auth_connectors
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net"
"oc-auth/conf"
"strings"
"sync"
"time"
"github.com/coocood/freecache"
"github.com/go-ldap/ldap/v3"
"github.com/i-core/rlog"
"go.uber.org/zap"
)
var (
// errInvalidCredentials is an error that happens when a user's password is invalid.
errInvalidCredentials = fmt . Errorf ( "invalid credentials" )
// errConnectionTimeout is an error that happens when no one LDAP endpoint responds.
errConnectionTimeout = fmt . Errorf ( "connection timeout" )
// errMissedUsername is an error that happens
errMissedUsername = errors . New ( "username is missed" )
// errUnknownUsername is an error that happens
errUnknownUsername = errors . New ( "unknown username" )
)
type conn interface {
Bind ( bindDN , password string ) error
2025-01-17 17:24:08 +01:00
SearchRoles ( attrs ... string ) ( [ ] map [ string ] [ ] string , error )
SearchUser ( user string , attrs ... string ) ( [ ] map [ string ] [ ] string , error )
SearchUserRoles ( user string , attrs ... string ) ( [ ] map [ string ] [ ] string , error )
2024-10-28 14:58:11 +01:00
Close ( ) error
}
type connector interface {
Connect ( ctx context . Context , addr string ) ( conn , error )
}
// Config is a LDAP configuration.
type Config struct {
Endpoints [ ] string ` envconfig:"endpoints" required:"true" desc:"a LDAP's server URLs as \"<address>:<port>\"" `
BindDN string ` envconfig:"binddn" desc:"a LDAP bind DN" `
BindPass string ` envconfig:"bindpw" json:"-" desc:"a LDAP bind password" `
BaseDN string ` envconfig:"basedn" required:"true" desc:"a LDAP base DN for searching users" `
AttrClaims map [ string ] string ` envconfig:"attr_claims" default:"name:name,sn:family_name,givenName:given_name,mail:email" desc:"a mapping of LDAP attributes to OpenID connect claims" `
RoleBaseDN string ` envconfig:"role_basedn" required:"true" desc:"a LDAP base DN for searching roles" `
RoleAttr string ` envconfig:"role_attr" default:"description" desc:"a LDAP group's attribute that contains a role's name" `
RoleClaim string ` envconfig:"role_claim" default:"https://github.com/i-core/werther/claims/roles" desc:"a name of an OpenID Connect claim that contains user roles" `
CacheSize int ` envconfig:"cache_size" default:"512" desc:"a user info cache's size in KiB" `
CacheTTL time . Duration ` envconfig:"cache_ttl" default:"30m" desc:"a user info cache TTL" `
IsTLS bool ` envconfig:"is_tls" default:"false" desc:"should LDAP connection be established via TLS" `
FlatRoleClaims bool ` envconfig:"flat_role_claims" desc:"add roles claim as single list" `
}
// New creates a new LDAP client.
func New ( ) * Client {
cnf := Config {
Endpoints : strings . Split ( conf . GetConfig ( ) . LDAPEndpoints , "," ) ,
BindDN : conf . GetConfig ( ) . LDAPBindDN ,
BindPass : conf . GetConfig ( ) . LDAPBindPW ,
BaseDN : conf . GetConfig ( ) . LDAPBaseDN ,
RoleBaseDN : conf . GetConfig ( ) . LDAPRoleBaseDN ,
}
return & Client {
Config : cnf ,
connector : & ldapConnector { BaseDN : cnf . BaseDN , RoleBaseDN : cnf . RoleBaseDN , IsTLS : cnf . IsTLS } ,
cache : freecache . NewCache ( cnf . CacheSize * 1024 ) ,
}
}
type Client struct {
Config
connector connector
cache * freecache . Cache
}
2025-01-17 17:24:08 +01:00
func ( cli * Client ) Authenticate ( ctx context . Context , username string , password string ) ( bool , error ) {
2024-10-28 14:58:11 +01:00
if username == "" || password == "" {
return false , nil
}
var cancel context . CancelFunc
ctx , cancel = context . WithCancel ( ctx )
cn , ok := <- cli . connect ( ctx )
cancel ( )
if ! ok {
return false , errConnectionTimeout
}
defer cn . Close ( )
// Find a user DN by his or her username.
details , err := cli . findBasicUserDetails ( cn , username , [ ] string { "dn" } )
if err != nil {
return false , err
}
if details == nil {
return false , nil
}
2025-01-17 17:24:08 +01:00
a := details [ "dn" ]
if err := cn . Bind ( a [ 0 ] , password ) ; err != nil {
2024-10-28 14:58:11 +01:00
if err == errInvalidCredentials {
return false , nil
}
return false , err
}
// Clear the claims' cache because of possible re-authentication. We don't want stale claims after re-login.
if ok := cli . cache . Del ( [ ] byte ( username ) ) ; ok {
log := rlog . FromContext ( ctx )
log . Debug ( "Cleared user's OIDC claims in the cache" )
}
return true , nil
}
2025-01-17 17:24:08 +01:00
func ( cli * Client ) GetRoles ( ctx context . Context ) ( map [ string ] LDAPRoles , error ) {
var cancel context . CancelFunc
ctx , cancel = context . WithCancel ( ctx )
cn , ok := <- cli . connect ( ctx )
cancel ( )
if ! ok {
return map [ string ] LDAPRoles { } , errConnectionTimeout
}
defer cn . Close ( )
// Find a user DN by his or her username.
return cli . findRoles ( cn , "dn" , "member" , "uniqueMember" )
}
2024-10-28 14:58:11 +01:00
// Claim is the FindOIDCClaims result struct
type LDAPClaim struct {
Code string // the root claim name
Name string // the claim name
Value interface { } // the value
}
2025-01-17 17:24:08 +01:00
type LDAPRoles struct {
Members map [ string ] [ ] string
}
2024-10-28 14:58:11 +01:00
// FindOIDCClaims finds all OIDC claims for a user.
func ( cli * Client ) FindOIDCClaims ( ctx context . Context , username string ) ( [ ] LDAPClaim , error ) {
if username == "" {
return nil , errMissedUsername
}
log := rlog . FromContext ( ctx ) . Sugar ( )
// Retrieving from LDAP is slow. So, we try to get claims for the given username from the cache.
switch cdata , err := cli . cache . Get ( [ ] byte ( username ) ) ; err {
case nil :
var claims [ ] LDAPClaim
if err = json . Unmarshal ( cdata , & claims ) ; err != nil {
log . Info ( "Failed to unmarshal user's OIDC claims" , zap . Error ( err ) , "data" , cdata )
return nil , err
}
log . Debugw ( "Retrieved user's OIDC claims from the cache" , "claims" , claims )
return claims , nil
case freecache . ErrNotFound :
log . Debug ( "User's OIDC claims is not found in the cache" )
default :
log . Infow ( "Failed to retrieve user's OIDC claims from the cache" , zap . Error ( err ) )
}
// Try to make multiple TCP connections to the LDAP server for getting claims.
// Accept the first one, and cancel others.
var cancel context . CancelFunc
ctx , cancel = context . WithCancel ( ctx )
cn , ok := <- cli . connect ( ctx )
cancel ( )
if ! ok {
return nil , errConnectionTimeout
}
defer cn . Close ( )
// We need to find LDAP attribute's names for all required claims.
attrs := [ ] string { "dn" }
for k := range cli . AttrClaims {
attrs = append ( attrs , k )
}
// Find the attributes in the LDAP server.
details , err := cli . findBasicUserDetails ( cn , username , attrs )
if err != nil {
return nil , err
}
if details == nil {
return nil , errUnknownUsername
}
log . Infow ( "Retrieved user's info from LDAP" , "details" , details )
// Transform the retrieved attributes to corresponding claims.
claims := make ( [ ] LDAPClaim , 0 , len ( details ) )
for attr , v := range details {
if claim , ok := cli . AttrClaims [ attr ] ; ok {
claims = append ( claims , LDAPClaim { claim , claim , v } )
}
}
// User's roles is stored in LDAP as groups. We find all groups in a role's DN
// that include the user as a member.
entries , err := cn . SearchUserRoles ( fmt . Sprintf ( "%s" , details [ "dn" ] ) , "dn" , cli . RoleAttr )
if err != nil {
return nil , err
}
roles := make ( map [ string ] interface { } )
for _ , entry := range entries {
2025-01-17 17:24:08 +01:00
roleDNs , ok := entry [ "dn" ]
if ! ok || len ( roleDNs ) == 0 {
2024-10-28 14:58:11 +01:00
log . Infow ( "No required LDAP attribute for a role" , "ldapAttribute" , "dn" , "entry" , entry )
continue
}
2025-01-17 17:24:08 +01:00
roleDN := roleDNs [ 0 ]
2024-10-28 14:58:11 +01:00
if entry [ cli . RoleAttr ] == nil {
log . Infow ( "No required LDAP attribute for a role" , "ldapAttribute" , cli . RoleAttr , "roleDN" , roleDN )
continue
}
// Ensure that a role's DN is inside of the role's base DN.
// It's sufficient to compare the DN's suffix with the base DN.
n , k := len ( roleDN ) , len ( cli . RoleBaseDN )
if n < k || ! strings . EqualFold ( roleDN [ n - k : ] , cli . RoleBaseDN ) {
panic ( "You should never see that" )
}
// The DN without the role's base DN must contain a CN and OU
// where the CN is for uniqueness only, and the OU is an application id.
path := strings . Split ( roleDN [ : n - k - 1 ] , "," )
if len ( path ) != 2 {
log . Infow ( "A role's DN without the role's base DN must contain two nodes only" ,
"roleBaseDN" , cli . RoleBaseDN , "roleDN" , roleDN )
continue
}
appID := path [ 1 ] [ len ( "OU=" ) : ]
var appRoles [ ] interface { }
if v := roles [ appID ] ; v != nil {
appRoles = v . ( [ ] interface { } )
}
appRoles = append ( appRoles , entry [ cli . RoleAttr ] )
roles [ appID ] = appRoles
}
claims = append ( claims , LDAPClaim { cli . RoleClaim , cli . RoleClaim , roles } )
if cli . FlatRoleClaims {
for appID , appRoles := range roles {
claims = append ( claims , LDAPClaim { cli . RoleClaim , cli . RoleClaim + "/" + appID , appRoles } )
}
}
// Save the claims in the cache for future queries.
cdata , err := json . Marshal ( claims )
if err != nil {
log . Infow ( "Failed to marshal user's OIDC claims for caching" , zap . Error ( err ) , "claims" , claims )
}
if err = cli . cache . Set ( [ ] byte ( username ) , cdata , int ( cli . CacheTTL . Seconds ( ) ) ) ; err != nil {
log . Infow ( "Failed to store user's OIDC claims into the cache" , zap . Error ( err ) , "claims" , claims )
}
return claims , nil
}
func ( cli * Client ) connect ( ctx context . Context ) <- chan conn {
var (
wg sync . WaitGroup
ch = make ( chan conn )
)
wg . Add ( len ( cli . Endpoints ) )
for _ , addr := range cli . Endpoints {
go func ( addr string ) {
defer wg . Done ( )
cn , err := cli . connector . Connect ( ctx , addr )
if err != nil {
fmt . Println ( "Failed to create a LDAP connection" , "address" , addr )
return
}
select {
case <- ctx . Done ( ) :
cn . Close ( )
fmt . Println ( "a LDAP connection is cancelled" , "address" , addr )
return
case ch <- cn :
}
} ( addr )
}
go func ( ) {
wg . Wait ( )
close ( ch )
} ( )
return ch
}
2025-01-17 17:24:08 +01:00
func ( cli * Client ) findRoles ( cn conn , attrs ... string ) ( map [ string ] LDAPRoles , error ) {
if cli . BindDN != "" {
// We need to login to a LDAP server with a service account for retrieving user data.
if err := cn . Bind ( cli . BindDN , cli . BindPass ) ; err != nil {
return map [ string ] LDAPRoles { } , errors . New ( err . Error ( ) + " : failed to login to a LDAP woth a service account" )
}
}
entries , err := cn . SearchRoles ( attrs ... )
fmt . Println ( "entries" , entries )
if err != nil {
return map [ string ] LDAPRoles { } , err
}
claims := map [ string ] LDAPRoles { }
for _ , entry := range entries {
roleDNs , ok := entry [ "dn" ]
if ! ok || len ( roleDNs ) == 0 {
continue
}
roleDN := roleDNs [ 0 ]
// Ensure that a role's DN is inside of the role's base DN.
// It's sufficient to compare the DN's suffix with the base DN.
n , k := len ( roleDN ) , len ( cli . RoleBaseDN )
if n < k || ! strings . EqualFold ( roleDN [ n - k : ] , cli . RoleBaseDN ) {
panic ( "You should never see that" )
}
// The DN without the role's base DN must contain a CN and OU
// where the CN is for uniqueness only, and the OU is an application id.
path := strings . Split ( roleDN [ : n - k - 1 ] , "," )
if len ( path ) != 2 {
continue
}
appID := path [ 1 ] [ len ( "OU=" ) : ]
if _ , ok := claims [ appID ] ; ! ok {
claims [ appID ] = LDAPRoles {
Members : map [ string ] [ ] string { } ,
}
}
role := path [ 0 ] [ len ( "cn=" ) : ]
if claims [ appID ] . Members [ role ] == nil {
claims [ appID ] . Members [ role ] = [ ] string { }
}
fmt . Println ( "entry" , entry )
memberDNs , ok := entry [ "member" ]
for _ , memberDN := range memberDNs {
if ! ok || memberDN == "" {
continue
}
path = strings . Split ( memberDN [ : n - k - 1 ] , "," )
if len ( path ) < 1 {
continue
}
member := strings . Split ( path [ 0 ] [ len ( "uid=" ) : ] , "," )
claims [ appID ] . Members [ role ] = append ( claims [ appID ] . Members [ role ] , member [ 0 ] )
}
memberDNs , ok = entry [ "uniqueMember" ]
for _ , memberDN := range memberDNs {
if ! ok || memberDN == "" {
continue
}
path = strings . Split ( memberDN [ : n - k - 1 ] , "," )
if len ( path ) < 1 {
continue
}
member := strings . Split ( path [ 0 ] [ len ( "uid=" ) : ] , "," )
claims [ appID ] . Members [ role ] = append ( claims [ appID ] . Members [ role ] , member [ 0 ] )
}
}
return claims , nil
}
2024-10-28 14:58:11 +01:00
// findBasicUserDetails finds user's LDAP attributes that were specified. It returns nil if no such user.
2025-01-17 17:24:08 +01:00
func ( cli * Client ) findBasicUserDetails ( cn conn , username string , attrs [ ] string ) ( map [ string ] [ ] string , error ) {
2024-10-28 14:58:11 +01:00
if cli . BindDN != "" {
// We need to login to a LDAP server with a service account for retrieving user data.
if err := cn . Bind ( cli . BindDN , cli . BindPass ) ; err != nil {
return nil , errors . New ( err . Error ( ) + " : failed to login to a LDAP woth a service account" )
}
}
entries , err := cn . SearchUser ( username , attrs ... )
if err != nil {
return nil , err
}
if len ( entries ) != 1 {
// We didn't find the user.
return nil , nil
}
var (
entry = entries [ 0 ]
2025-01-17 17:24:08 +01:00
details = make ( map [ string ] [ ] string )
2024-10-28 14:58:11 +01:00
)
for _ , attr := range attrs {
if v , ok := entry [ attr ] ; ok {
details [ attr ] = v
}
}
return details , nil
}
type ldapConnector struct {
BaseDN string
RoleBaseDN string
IsTLS bool
}
func ( c * ldapConnector ) Connect ( ctx context . Context , addr string ) ( conn , error ) {
d := net . Dialer { Timeout : ldap . DefaultTimeout }
tcpcn , err := d . DialContext ( ctx , "tcp" , addr )
if err != nil {
return nil , err
}
if c . IsTLS {
tlscn , err := tls . DialWithDialer ( & d , "tcp" , addr , nil )
if err != nil {
return nil , err
}
tcpcn = tlscn
}
ldapcn := ldap . NewConn ( tcpcn , c . IsTLS )
ldapcn . Start ( )
return & ldapConn { Conn : ldapcn , BaseDN : c . BaseDN , RoleBaseDN : c . RoleBaseDN } , nil
}
type ldapConn struct {
* ldap . Conn
BaseDN string
RoleBaseDN string
}
func ( c * ldapConn ) Bind ( bindDN , password string ) error {
err := c . Conn . Bind ( bindDN , password )
if ldapErr , ok := err . ( * ldap . Error ) ; ok && ldapErr . ResultCode == ldap . LDAPResultInvalidCredentials {
return errInvalidCredentials
}
return err
}
2025-01-17 17:24:08 +01:00
func ( c * ldapConn ) SearchUser ( user string , attrs ... string ) ( [ ] map [ string ] [ ] string , error ) {
2024-10-28 14:58:11 +01:00
query := fmt . Sprintf (
"(&(|(objectClass=organizationalPerson)(objectClass=inetOrgPerson))" +
"(|(uid=%[1]s)(mail=%[1]s)(userPrincipalName=%[1]s)(sAMAccountName=%[1]s)))" , user )
return c . searchEntries ( c . BaseDN , query , attrs )
}
2025-01-17 17:24:08 +01:00
func ( c * ldapConn ) SearchUserRoles ( user string , attrs ... string ) ( [ ] map [ string ] [ ] string , error ) {
2024-10-28 14:58:11 +01:00
query := fmt . Sprintf ( "(|" +
2025-01-17 17:24:08 +01:00
"(&(|(objectClass=group)(objectClass=groupOfNames)(objectClass=groupofnames))(member=%[1]s))" +
2024-10-28 14:58:11 +01:00
"(&(objectClass=groupOfUniqueNames)(uniqueMember=%[1]s))" +
")" , user )
return c . searchEntries ( c . RoleBaseDN , query , attrs )
}
2025-01-17 17:24:08 +01:00
func ( c * ldapConn ) SearchRoles ( attrs ... string ) ( [ ] map [ string ] [ ] string , error ) {
query := "(|(&(|(objectClass=group)(objectClass=groupOfNames)(objectClass=groupofnames))))"
return c . searchEntries ( c . RoleBaseDN , query , attrs )
}
2024-10-28 14:58:11 +01:00
// searchEntries executes a LDAP query, and returns a result as entries where each entry is mapping of LDAP attributes.
2025-01-17 17:24:08 +01:00
func ( c * ldapConn ) searchEntries ( baseDN , query string , attrs [ ] string ) ( [ ] map [ string ] [ ] string , error ) {
2024-10-28 14:58:11 +01:00
req := ldap . NewSearchRequest ( baseDN , ldap . ScopeWholeSubtree , ldap . NeverDerefAliases , 0 , 0 , false , query , attrs , nil )
res , err := c . Search ( req )
if err != nil {
return nil , err
}
2025-01-17 17:24:08 +01:00
var entries [ ] map [ string ] [ ] string
2024-10-28 14:58:11 +01:00
for _ , v := range res . Entries {
2025-01-17 17:24:08 +01:00
entry := map [ string ] [ ] string { "dn" : [ ] string { v . DN } }
2024-10-28 14:58:11 +01:00
for _ , attr := range v . Attributes {
// We need the first value only for the named attribute.
2025-01-17 17:24:08 +01:00
entry [ attr . Name ] = attr . Values
2024-10-28 14:58:11 +01:00
}
entries = append ( entries , entry )
}
return entries , nil
}