refactor: move oauth providers into services (non-working)

This commit is contained in:
Stavros
2025-08-25 17:50:34 +03:00
parent dfdc656145
commit 44f35af3bf
15 changed files with 544 additions and 778 deletions

View File

@@ -0,0 +1,465 @@
package service
import (
"fmt"
"regexp"
"strings"
"sync"
"time"
"tinyauth/internal/types"
"tinyauth/internal/utils"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
type AuthServiceConfig struct {
Users types.Users
OauthWhitelist string
SessionExpiry int
CookieSecure bool
Domain string
LoginTimeout int
LoginMaxRetries int
SessionCookieName string
HMACSecret string
EncryptionSecret string
}
type AuthService struct {
Config AuthServiceConfig
Docker *DockerService
LoginAttempts map[string]*types.LoginAttempt
LoginMutex sync.RWMutex
Store *sessions.CookieStore
LDAP *LdapService
}
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService) *AuthService {
return &AuthService{
Config: config,
Docker: docker,
LoginAttempts: make(map[string]*types.LoginAttempt),
LDAP: ldap,
}
}
func (auth *AuthService) Init() error {
store := sessions.NewCookieStore([]byte(auth.Config.HMACSecret), []byte(auth.Config.EncryptionSecret))
store.Options = &sessions.Options{
Path: "/",
MaxAge: auth.Config.SessionExpiry,
Secure: auth.Config.CookieSecure,
HttpOnly: true,
Domain: fmt.Sprintf(".%s", auth.Config.Domain),
}
return nil
}
func (auth *AuthService) GetSession(c *gin.Context) (*sessions.Session, error) {
session, err := auth.Store.Get(c.Request, auth.Config.SessionCookieName)
// If there was an error getting the session, it might be invalid so let's clear it and retry
if err != nil {
log.Error().Err(err).Msg("Invalid session, clearing cookie and retrying")
c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.CookieSecure, true)
session, err = auth.Store.Get(c.Request, auth.Config.SessionCookieName)
if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return nil, err
}
}
return session, nil
}
func (auth *AuthService) SearchUser(username string) types.UserSearch {
log.Debug().Str("username", username).Msg("Searching for user")
// Check local users first
if auth.GetLocalUser(username).Username != "" {
log.Debug().Str("username", username).Msg("Found local user")
return types.UserSearch{
Username: username,
Type: "local",
}
}
// If no user found, check LDAP
if auth.LDAP != nil {
log.Debug().Str("username", username).Msg("Checking LDAP for user")
userDN, err := auth.LDAP.Search(username)
if err != nil {
log.Warn().Err(err).Str("username", username).Msg("Failed to find user in LDAP")
return types.UserSearch{}
}
return types.UserSearch{
Username: userDN,
Type: "ldap",
}
}
return types.UserSearch{
Type: "unknown",
}
}
func (auth *AuthService) VerifyUser(search types.UserSearch, password string) bool {
// Authenticate the user based on the type
switch search.Type {
case "local":
// If local user, get the user and check the password
user := auth.GetLocalUser(search.Username)
return auth.CheckPassword(user, password)
case "ldap":
// If LDAP is configured, bind to the LDAP server with the user DN and password
if auth.LDAP != nil {
log.Debug().Str("username", search.Username).Msg("Binding to LDAP for user authentication")
err := auth.LDAP.Bind(search.Username, password)
if err != nil {
log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP")
return false
}
// Rebind with the service account to reset the connection
err = auth.LDAP.Bind(auth.LDAP.Config.BindDN, auth.LDAP.Config.BindPassword)
if err != nil {
log.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
return false
}
log.Debug().Str("username", search.Username).Msg("LDAP authentication successful")
return true
}
default:
log.Warn().Str("type", search.Type).Msg("Unknown user type for authentication")
return false
}
// If no user found or authentication failed, return false
log.Warn().Str("username", search.Username).Msg("User authentication failed")
return false
}
func (auth *AuthService) GetLocalUser(username string) types.User {
// Loop through users and return the user if the username matches
log.Debug().Str("username", username).Msg("Searching for local user")
for _, user := range auth.Config.Users {
if user.Username == username {
return user
}
}
// If no user found, return an empty user
log.Warn().Str("username", username).Msg("Local user not found")
return types.User{}
}
func (auth *AuthService) CheckPassword(user types.User, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
}
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
auth.LoginMutex.RLock()
defer auth.LoginMutex.RUnlock()
// Return false if rate limiting is not configured
if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 {
return false, 0
}
// Check if the identifier exists in the map
attempt, exists := auth.LoginAttempts[identifier]
if !exists {
return false, 0
}
// If account is locked, check if lock time has expired
if attempt.LockedUntil.After(time.Now()) {
// Calculate remaining lockout time in seconds
remaining := int(time.Until(attempt.LockedUntil).Seconds())
return true, remaining
}
// Lock has expired
return false, 0
}
func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
// Skip if rate limiting is not configured
if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 {
return
}
auth.LoginMutex.Lock()
defer auth.LoginMutex.Unlock()
// Get current attempt record or create a new one
attempt, exists := auth.LoginAttempts[identifier]
if !exists {
attempt = &types.LoginAttempt{}
auth.LoginAttempts[identifier] = attempt
}
// Update last attempt time
attempt.LastAttempt = time.Now()
// If successful login, reset failed attempts
if success {
attempt.FailedAttempts = 0
attempt.LockedUntil = time.Time{} // Reset lock time
return
}
// Increment failed attempts
attempt.FailedAttempts++
// If max retries reached, lock the account
if attempt.FailedAttempts >= auth.Config.LoginMaxRetries {
attempt.LockedUntil = time.Now().Add(time.Duration(auth.Config.LoginTimeout) * time.Second)
log.Warn().Str("identifier", identifier).Int("timeout", auth.Config.LoginTimeout).Msg("Account locked due to too many failed login attempts")
}
}
func (auth *AuthService) EmailWhitelisted(email string) bool {
return utils.CheckFilter(auth.Config.OauthWhitelist, email)
}
func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) error {
log.Debug().Msg("Creating session cookie")
session, err := auth.GetSession(c)
if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return err
}
log.Debug().Msg("Setting session cookie")
var sessionExpiry int
if data.TotpPending {
sessionExpiry = 3600
} else {
sessionExpiry = auth.Config.SessionExpiry
}
session.Values["username"] = data.Username
session.Values["name"] = data.Name
session.Values["email"] = data.Email
session.Values["provider"] = data.Provider
session.Values["expiry"] = time.Now().Add(time.Duration(sessionExpiry) * time.Second).Unix()
session.Values["totpPending"] = data.TotpPending
session.Values["oauthGroups"] = data.OAuthGroups
err = session.Save(c.Request, c.Writer)
if err != nil {
log.Error().Err(err).Msg("Failed to save session")
return err
}
return nil
}
func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
log.Debug().Msg("Deleting session cookie")
session, err := auth.GetSession(c)
if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return err
}
// Delete all values in the session
for key := range session.Values {
delete(session.Values, key)
}
err = session.Save(c.Request, c.Writer)
if err != nil {
log.Error().Err(err).Msg("Failed to save session")
return err
}
return nil
}
func (auth *AuthService) GetSessionCookie(c *gin.Context) (types.SessionCookie, error) {
log.Debug().Msg("Getting session cookie")
session, err := auth.GetSession(c)
if err != nil {
log.Error().Err(err).Msg("Failed to get session")
return types.SessionCookie{}, err
}
log.Debug().Msg("Got session")
username, usernameOk := session.Values["username"].(string)
email, emailOk := session.Values["email"].(string)
name, nameOk := session.Values["name"].(string)
provider, providerOK := session.Values["provider"].(string)
expiry, expiryOk := session.Values["expiry"].(int64)
totpPending, totpPendingOk := session.Values["totpPending"].(bool)
oauthGroups, oauthGroupsOk := session.Values["oauthGroups"].(string)
// If any data is missing, delete the session cookie
if !usernameOk || !providerOK || !expiryOk || !totpPendingOk || !emailOk || !nameOk || !oauthGroupsOk {
log.Warn().Msg("Session cookie is invalid")
auth.DeleteSessionCookie(c)
return types.SessionCookie{}, nil
}
// If the session cookie has expired, delete it
if time.Now().Unix() > expiry {
log.Warn().Msg("Session cookie expired")
auth.DeleteSessionCookie(c)
return types.SessionCookie{}, nil
}
log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Bool("totpPending", totpPending).Str("name", name).Str("email", email).Str("oauthGroups", oauthGroups).Msg("Parsed cookie")
return types.SessionCookie{
Username: username,
Name: name,
Email: email,
Provider: provider,
TotpPending: totpPending,
OAuthGroups: oauthGroups,
}, nil
}
func (auth *AuthService) UserAuthConfigured() bool {
// If there are users or LDAP is configured, return true
return len(auth.Config.Users) > 0 || auth.LDAP != nil
}
func (auth *AuthService) ResourceAllowed(c *gin.Context, context types.UserContext, labels types.Labels) bool {
if context.OAuth {
log.Debug().Msg("Checking OAuth whitelist")
return utils.CheckFilter(labels.OAuth.Whitelist, context.Email)
}
log.Debug().Msg("Checking users")
return utils.CheckFilter(labels.Users, context.Username)
}
func (auth *AuthService) OAuthGroup(c *gin.Context, context types.UserContext, labels types.Labels) bool {
if labels.OAuth.Groups == "" {
return true
}
// Check if we are using the generic oauth provider
if context.Provider != "generic" {
log.Debug().Msg("Not using generic provider, skipping group check")
return true
}
// Split the groups by comma (no need to parse since they are from the API response)
oauthGroups := strings.Split(context.OAuthGroups, ",")
// For every group check if it is in the required groups
for _, group := range oauthGroups {
if utils.CheckFilter(labels.OAuth.Groups, group) {
log.Debug().Str("group", group).Msg("Group is in required groups")
return true
}
}
// No groups matched
log.Debug().Msg("No groups matched")
return false
}
func (auth *AuthService) AuthEnabled(uri string, labels types.Labels) (bool, error) {
// If the label is empty, auth is enabled
if labels.Allowed == "" {
return true, nil
}
// Compile regex
regex, err := regexp.Compile(labels.Allowed)
// If there is an error, invalid regex, auth enabled
if err != nil {
log.Error().Err(err).Msg("Invalid regex")
return true, err
}
// If the regex matches the URI, auth is not enabled
if regex.MatchString(uri) {
return false, nil
}
// Auth enabled
return true, nil
}
func (auth *AuthService) GetBasicAuth(c *gin.Context) *types.User {
username, password, ok := c.Request.BasicAuth()
if !ok {
return nil
}
return &types.User{
Username: username,
Password: password,
}
}
func (auth *AuthService) CheckIP(labels types.Labels, ip string) bool {
// Check if the IP is in block list
for _, blocked := range labels.IP.Block {
res, err := utils.FilterIP(blocked, ip)
if err != nil {
log.Error().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue
}
if res {
log.Warn().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access")
return false
}
}
// For every IP in the allow list, check if the IP matches
for _, allowed := range labels.IP.Allow {
res, err := utils.FilterIP(allowed, ip)
if err != nil {
log.Error().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue
}
if res {
log.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access")
return true
}
}
// If not in allowed range and allowed range is not empty, deny access
if len(labels.IP.Allow) > 0 {
log.Warn().Str("ip", ip).Msg("IP not in allow list, denying access")
return false
}
log.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default")
return true
}
func (auth *AuthService) BypassedIP(labels types.Labels, ip string) bool {
// For every IP in the bypass list, check if the IP matches
for _, bypassed := range labels.IP.Bypass {
res, err := utils.FilterIP(bypassed, ip)
if err != nil {
log.Error().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue
}
if res {
log.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access")
return true
}
}
log.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
return false
}

View File

@@ -0,0 +1,102 @@
package service
import (
"context"
"strings"
"tinyauth/internal/config"
"tinyauth/internal/utils"
container "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
type DockerService struct {
Client *client.Client
Context context.Context
}
func NewDockerService() *DockerService {
return &DockerService{}
}
func (docker *DockerService) Init() error {
client, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return err
}
ctx := context.Background()
client.NegotiateAPIVersion(ctx)
return nil
}
func (docker *DockerService) GetContainers() ([]container.Summary, error) {
containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{})
if err != nil {
return nil, err
}
return containers, nil
}
func (docker *DockerService) InspectContainer(containerId string) (container.InspectResponse, error) {
inspect, err := docker.Client.ContainerInspect(docker.Context, containerId)
if err != nil {
return container.InspectResponse{}, err
}
return inspect, nil
}
func (docker *DockerService) DockerConnected() bool {
_, err := docker.Client.Ping(docker.Context)
return err == nil
}
func (docker *DockerService) GetLabels(app string, domain string) (config.Labels, error) {
isConnected := docker.DockerConnected()
if !isConnected {
log.Debug().Msg("Docker not connected, returning empty labels")
return config.Labels{}, nil
}
log.Debug().Msg("Getting containers")
containers, err := docker.GetContainers()
if err != nil {
log.Error().Err(err).Msg("Error getting containers")
return config.Labels{}, err
}
for _, container := range containers {
inspect, err := docker.InspectContainer(container.ID)
if err != nil {
log.Warn().Str("id", container.ID).Err(err).Msg("Error inspecting container, skipping")
continue
}
log.Debug().Str("id", inspect.ID).Msg("Getting labels for container")
labels, err := utils.GetLabels(inspect.Config.Labels)
if err != nil {
log.Warn().Str("id", container.ID).Err(err).Msg("Error getting container labels, skipping")
continue
}
// Check if the container matches the ID or domain
for _, lDomain := range labels.Domain {
if lDomain == domain {
log.Debug().Str("id", inspect.ID).Msg("Found matching container by domain")
return labels, nil
}
}
if strings.TrimPrefix(inspect.Name, "/") == app {
log.Debug().Str("id", inspect.ID).Msg("Found matching container by name")
return labels, nil
}
}
log.Debug().Msg("No matching container found, returning empty labels")
return config.Labels{}, nil
}

View File

@@ -0,0 +1,114 @@
package service
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"tinyauth/internal/config"
"golang.org/x/oauth2"
)
type GenericOAuthService struct {
Config oauth2.Config
Context context.Context
Token *oauth2.Token
Verifier string
InsecureSkipVerify bool
ServiceName string
UserinfoURL string
}
func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthService {
return &GenericOAuthService{
Config: oauth2.Config{
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
RedirectURL: config.RedirectURL,
Scopes: config.Scopes,
Endpoint: oauth2.Endpoint{
AuthURL: config.AuthURL,
TokenURL: config.TokenURL,
},
},
InsecureSkipVerify: config.InsecureSkipVerify,
ServiceName: config.Name,
UserinfoURL: config.UserinfoURL,
}
}
func (generic *GenericOAuthService) Init() error {
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: generic.InsecureSkipVerify,
MinVersion: tls.VersionTLS12,
},
}
httpClient := &http.Client{
Transport: transport,
}
ctx := context.Background()
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
verifier := oauth2.GenerateVerifier()
generic.Context = ctx
generic.Verifier = verifier
return nil
}
func (generic *GenericOAuthService) Name() string {
return generic.ServiceName
}
func (generic *GenericOAuthService) GenerateState() string {
b := make([]byte, 128)
rand.Read(b)
state := base64.URLEncoding.EncodeToString(b)
return state
}
func (generic *GenericOAuthService) GetAuthURL(state string) string {
return generic.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(generic.Verifier))
}
func (generic *GenericOAuthService) VerifyCode(code string) error {
token, err := generic.Config.Exchange(generic.Context, code, oauth2.VerifierOption(generic.Verifier))
if err != nil {
return nil
}
generic.Token = token
return nil
}
func (generic *GenericOAuthService) Userinfo() (config.Claims, error) {
var user config.Claims
client := generic.Config.Client(generic.Context, generic.Token)
res, err := client.Get(generic.UserinfoURL)
if err != nil {
return user, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return user, err
}
err = json.Unmarshal(body, &user)
if err != nil {
return user, err
}
return user, nil
}

View File

@@ -0,0 +1,144 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"tinyauth/internal/config"
"golang.org/x/oauth2"
)
var GithubOAuthScopes = []string{"user:email", "read:user"}
type GithubEmailResponse []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
}
type GithubUserInfoResponse struct {
Login string `json:"login"`
Name string `json:"name"`
}
type GithubOAuthService struct {
Config oauth2.Config
Context context.Context
Token *oauth2.Token
Verifier string
}
func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService {
return &GithubOAuthService{
Config: oauth2.Config{
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
RedirectURL: config.RedirectURL,
Scopes: GithubOAuthScopes,
},
}
}
func (github *GithubOAuthService) Init() error {
httpClient := &http.Client{}
ctx := context.Background()
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
verifier := oauth2.GenerateVerifier()
github.Context = ctx
github.Verifier = verifier
return nil
}
func (github *GithubOAuthService) Name() string {
return "github"
}
func (github *GithubOAuthService) GenerateState() string {
b := make([]byte, 128)
rand.Read(b)
state := base64.URLEncoding.EncodeToString(b)
return state
}
func (github *GithubOAuthService) GetAuthURL(state string) string {
return github.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(github.Verifier))
}
func (github *GithubOAuthService) VerifyCode(code string) error {
token, err := github.Config.Exchange(github.Context, code, oauth2.VerifierOption(github.Verifier))
if err != nil {
return nil
}
github.Token = token
return nil
}
func (github *GithubOAuthService) Userinfo() (config.Claims, error) {
var user config.Claims
client := github.Config.Client(github.Context, github.Token)
res, err := client.Get("https://api.github.com/user")
if err != nil {
return user, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return user, err
}
var userInfo GithubUserInfoResponse
err = json.Unmarshal(body, &userInfo)
if err != nil {
return user, err
}
res, err = client.Get("https://api.github.com/user/emails")
if err != nil {
return user, err
}
defer res.Body.Close()
body, err = io.ReadAll(res.Body)
if err != nil {
return user, err
}
var emails GithubEmailResponse
err = json.Unmarshal(body, &emails)
if err != nil {
return user, err
}
for _, email := range emails {
if email.Primary {
user.Email = email.Email
break
}
}
if len(emails) == 0 {
return user, errors.New("no emails found")
}
// Use first available email if no primary email was found
if user.Email == "" {
user.Email = emails[0].Email
}
user.PreferredUsername = userInfo.Login
user.Name = userInfo.Name
return user, nil
}

View File

@@ -0,0 +1,106 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strings"
"tinyauth/internal/config"
"golang.org/x/oauth2"
)
var GoogleOAuthScopes = []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}
type GoogleUserInfoResponse struct {
Email string `json:"email"`
Name string `json:"name"`
}
type GoogleOAuthService struct {
Config oauth2.Config
Context context.Context
Token *oauth2.Token
Verifier string
}
func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService {
return &GoogleOAuthService{
Config: oauth2.Config{
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
RedirectURL: config.RedirectURL,
Scopes: GoogleOAuthScopes,
},
}
}
func (google *GoogleOAuthService) Init() error {
httpClient := &http.Client{}
ctx := context.Background()
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
verifier := oauth2.GenerateVerifier()
google.Context = ctx
google.Verifier = verifier
return nil
}
func (google *GoogleOAuthService) Name() string {
return "google"
}
func (oauth *GoogleOAuthService) GenerateState() string {
b := make([]byte, 128)
rand.Read(b)
state := base64.URLEncoding.EncodeToString(b)
return state
}
func (google *GoogleOAuthService) GetAuthURL(state string) string {
return google.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(google.Verifier))
}
func (google *GoogleOAuthService) VerifyCode(code string) error {
token, err := google.Config.Exchange(google.Context, code, oauth2.VerifierOption(google.Verifier))
if err != nil {
return nil
}
google.Token = token
return nil
}
func (google *GoogleOAuthService) Userinfo() (config.Claims, error) {
var user config.Claims
client := google.Config.Client(google.Context, google.Token)
res, err := client.Get("https://www.googleapis.com/userinfo/v2/me")
if err != nil {
return config.Claims{}, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return config.Claims{}, err
}
var userInfo GoogleUserInfoResponse
err = json.Unmarshal(body, &userInfo)
if err != nil {
return config.Claims{}, err
}
user.PreferredUsername = strings.Split(userInfo.Email, "@")[0]
user.Name = userInfo.Name
user.Email = userInfo.Email
return user, nil
}

View File

@@ -0,0 +1,157 @@
package service
import (
"context"
"crypto/tls"
"fmt"
"time"
"github.com/cenkalti/backoff/v5"
ldapgo "github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog/log"
)
type LdapServiceConfig struct {
Address string
BindDN string
BindPassword string
BaseDN string
Insecure bool
SearchFilter string
}
type LdapService struct {
Config LdapServiceConfig
Conn *ldapgo.Conn
}
func NewLdapService(config LdapServiceConfig) *LdapService {
return &LdapService{
Config: config,
}
}
func (ldap *LdapService) Init() error {
_, err := ldap.connect()
if err != nil {
return fmt.Errorf("failed to connect to LDAP server: %w", err)
}
go func() {
for range time.Tick(time.Duration(5) * time.Minute) {
err := ldap.heartbeat()
if err != nil {
log.Error().Err(err).Msg("LDAP connection heartbeat failed")
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
log.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
continue
}
log.Info().Msg("Successfully reconnected to LDAP server")
}
}
}()
return nil
}
func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
log.Debug().Msg("Connecting to LDAP server")
conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{
InsecureSkipVerify: ldap.Config.Insecure,
MinVersion: tls.VersionTLS12,
}))
if err != nil {
return nil, err
}
log.Debug().Msg("Binding to LDAP server")
err = conn.Bind(ldap.Config.BindDN, ldap.Config.BindPassword)
if err != nil {
return nil, err
}
// Set and return the connection
ldap.Conn = conn
return conn, nil
}
func (ldap *LdapService) Search(username string) (string, error) {
// Escape the username to prevent LDAP injection
escapedUsername := ldapgo.EscapeFilter(username)
filter := fmt.Sprintf(ldap.Config.SearchFilter, escapedUsername)
searchRequest := ldapgo.NewSearchRequest(
ldap.Config.BaseDN,
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
filter,
[]string{"dn"},
nil,
)
searchResult, err := ldap.Conn.Search(searchRequest)
if err != nil {
return "", err
}
if len(searchResult.Entries) != 1 {
return "", fmt.Errorf("err multiple or no entries found for user %s", username)
}
userDN := searchResult.Entries[0].DN
return userDN, nil
}
func (ldap *LdapService) Bind(userDN string, password string) error {
err := ldap.Conn.Bind(userDN, password)
if err != nil {
return err
}
return nil
}
func (ldap *LdapService) heartbeat() error {
log.Debug().Msg("Performing LDAP connection heartbeat")
searchRequest := ldapgo.NewSearchRequest(
"",
ldapgo.ScopeBaseObject, ldapgo.NeverDerefAliases, 0, 0, false,
"(objectClass=*)",
[]string{},
nil,
)
_, err := ldap.Conn.Search(searchRequest)
if err != nil {
return err
}
// No error means the connection is alive
return nil
}
func (ldap *LdapService) reconnect() error {
log.Info().Msg("Reconnecting to LDAP server")
exp := backoff.NewExponentialBackOff()
exp.InitialInterval = 500 * time.Millisecond
exp.RandomizationFactor = 0.1
exp.Multiplier = 1.5
exp.Reset()
operation := func() (*ldapgo.Conn, error) {
ldap.Conn.Close()
conn, err := ldap.connect()
if err != nil {
return nil, nil
}
return conn, nil
}
_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
if err != nil {
return err
}
return nil
}