Files
tyto/backend/internal/auth/ldap.go
vikingowl 50c5811e22 feat: add authentication system with local and LDAP support
Auth Package (internal/auth/):
- Service: main auth orchestrator with multi-provider support
- LocalProvider: username/password auth with bcrypt hashing
- LDAPProvider: LDAP/Active Directory authentication with:
  - Service account bind for user search
  - User bind for password verification
  - Automatic user provisioning on first login
  - Group membership to role synchronization
- SessionManager: token-based session lifecycle
- Middleware: Gin middleware for route protection
- API: REST endpoints for login/logout/register

Security Features:
- bcrypt with cost factor 12 for password hashing
- Secure random 32-byte session tokens
- HTTP-only session cookies with SameSite=Lax
- Bearer token support for API clients
- Session expiration and cleanup
- Account disable with session invalidation

API Endpoints:
- POST /auth/login - Authenticate and get session
- POST /auth/logout - Invalidate current session
- POST /auth/logout/all - Invalidate all user sessions
- POST /auth/register - Create account (if enabled)
- GET /auth/me - Get current user info
- PUT /auth/me - Update profile
- PUT /auth/me/password - Change password

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 08:24:39 +01:00

277 lines
7.7 KiB
Go

package auth
import (
"context"
"crypto/tls"
"fmt"
"time"
"tyto/internal/database"
"github.com/go-ldap/ldap/v3"
)
// LDAPConfig contains LDAP/Active Directory settings.
type LDAPConfig struct {
Enabled bool `yaml:"enabled"`
URL string `yaml:"url"` // ldap://ad.example.com:389 or ldaps://...
BaseDN string `yaml:"base_dn"` // dc=example,dc=com
BindDN string `yaml:"bind_dn"` // cn=readonly,dc=example,dc=com
BindPassword string `yaml:"bind_password"` // Service account password
UserSearchBase string `yaml:"user_search_base"` // ou=users,dc=example,dc=com (optional)
UserFilter string `yaml:"user_filter"` // (sAMAccountName=%s) or (uid=%s)
GroupSearchBase string `yaml:"group_search_base"` // ou=groups,dc=example,dc=com
GroupFilter string `yaml:"group_filter"` // (member=%s)
UsernameAttribute string `yaml:"username_attr"` // sAMAccountName, uid, cn
EmailAttribute string `yaml:"email_attr"` // mail
DisplayNameAttr string `yaml:"display_name_attr"` // displayName, cn
GroupMembershipAttr string `yaml:"group_membership_attr"` // memberOf
GroupMappings map[string]string `yaml:"group_mappings"` // LDAP group -> Tyto role
StartTLS bool `yaml:"start_tls"` // Use STARTTLS
InsecureSkipVerify bool `yaml:"insecure_skip_verify"` // Skip TLS verification (dev only)
ConnectTimeout time.Duration `yaml:"connect_timeout"`
}
// DefaultLDAPConfig returns default LDAP configuration.
func DefaultLDAPConfig() *LDAPConfig {
return &LDAPConfig{
UserFilter: "(sAMAccountName=%s)",
GroupFilter: "(member=%s)",
UsernameAttribute: "sAMAccountName",
EmailAttribute: "mail",
DisplayNameAttr: "displayName",
GroupMembershipAttr: "memberOf",
ConnectTimeout: 10 * time.Second,
GroupMappings: make(map[string]string),
}
}
// LDAPProvider handles LDAP/Active Directory authentication.
type LDAPProvider struct {
db database.Database
config *LDAPConfig
}
// NewLDAPProvider creates a new LDAP authentication provider.
func NewLDAPProvider(db database.Database, config *LDAPConfig) *LDAPProvider {
if config.UserFilter == "" {
config.UserFilter = "(sAMAccountName=%s)"
}
if config.UsernameAttribute == "" {
config.UsernameAttribute = "sAMAccountName"
}
if config.EmailAttribute == "" {
config.EmailAttribute = "mail"
}
if config.ConnectTimeout == 0 {
config.ConnectTimeout = 10 * time.Second
}
return &LDAPProvider{
db: db,
config: config,
}
}
// Name returns the provider name.
func (p *LDAPProvider) Name() string {
return "ldap"
}
// Available returns true if LDAP is configured.
func (p *LDAPProvider) Available() bool {
return p.config != nil && p.config.Enabled && p.config.URL != ""
}
// Authenticate validates username and password against LDAP.
func (p *LDAPProvider) Authenticate(ctx context.Context, username, password string) (*database.User, error) {
if !p.Available() {
return nil, ErrInvalidCredentials
}
// Connect to LDAP server
conn, err := p.connect()
if err != nil {
return nil, fmt.Errorf("ldap connect: %w", err)
}
defer conn.Close()
// Bind with service account to search for user
if err := conn.Bind(p.config.BindDN, p.config.BindPassword); err != nil {
return nil, fmt.Errorf("ldap bind: %w", err)
}
// Search for user
searchBase := p.config.UserSearchBase
if searchBase == "" {
searchBase = p.config.BaseDN
}
filter := fmt.Sprintf(p.config.UserFilter, ldap.EscapeFilter(username))
searchRequest := ldap.NewSearchRequest(
searchBase,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
1, // SizeLimit: 1 result
int(p.config.ConnectTimeout.Seconds()),
false,
filter,
[]string{"dn", p.config.UsernameAttribute, p.config.EmailAttribute, p.config.DisplayNameAttr, p.config.GroupMembershipAttr},
nil,
)
result, err := conn.Search(searchRequest)
if err != nil {
return nil, fmt.Errorf("ldap search: %w", err)
}
if len(result.Entries) == 0 {
return nil, ErrUserNotFound
}
entry := result.Entries[0]
userDN := entry.DN
// Attempt to bind as the user to verify password
if err := conn.Bind(userDN, password); err != nil {
return nil, ErrInvalidCredentials
}
// Password verified, get or create user in local database
user, err := p.db.GetUserByUsername(ctx, username)
if err != nil {
return nil, err
}
email := entry.GetAttributeValue(p.config.EmailAttribute)
if user == nil {
// Create new user
user = &database.User{
ID: generateID(),
Username: username,
Email: email,
AuthProvider: database.AuthProviderLDAP,
LDAPDN: userDN,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := p.db.CreateUser(ctx, user); err != nil {
return nil, fmt.Errorf("create ldap user: %w", err)
}
} else {
// Update existing user info from LDAP
user.Email = email
user.LDAPDN = userDN
user.UpdatedAt = time.Now().UTC()
if err := p.db.UpdateUser(ctx, user); err != nil {
// Non-fatal
}
}
// Sync group memberships to roles
if len(p.config.GroupMappings) > 0 {
groups := entry.GetAttributeValues(p.config.GroupMembershipAttr)
p.syncRoles(ctx, user.ID, groups)
}
return user, nil
}
// connect establishes a connection to the LDAP server.
func (p *LDAPProvider) connect() (*ldap.Conn, error) {
var conn *ldap.Conn
var err error
// Determine if using LDAPS
if len(p.config.URL) > 5 && p.config.URL[:5] == "ldaps" {
tlsConfig := &tls.Config{
InsecureSkipVerify: p.config.InsecureSkipVerify,
}
conn, err = ldap.DialURL(p.config.URL, ldap.DialWithTLSConfig(tlsConfig))
} else {
conn, err = ldap.DialURL(p.config.URL)
}
if err != nil {
return nil, err
}
// Upgrade to TLS if StartTLS is enabled
if p.config.StartTLS {
tlsConfig := &tls.Config{
InsecureSkipVerify: p.config.InsecureSkipVerify,
}
if err := conn.StartTLS(tlsConfig); err != nil {
conn.Close()
return nil, err
}
}
return conn, nil
}
// syncRoles synchronizes LDAP group memberships to Tyto roles.
func (p *LDAPProvider) syncRoles(ctx context.Context, userID string, groups []string) {
// Get current roles
currentRoles, err := p.db.GetUserRoles(ctx, userID)
if err != nil {
return
}
// Build set of current role IDs
currentRoleIDs := make(map[string]bool)
for _, role := range currentRoles {
currentRoleIDs[role.ID] = true
}
// Determine which roles should be assigned based on LDAP groups
targetRoleIDs := make(map[string]bool)
for _, group := range groups {
if roleID, ok := p.config.GroupMappings[group]; ok {
targetRoleIDs[roleID] = true
}
}
// Add missing roles
for roleID := range targetRoleIDs {
if !currentRoleIDs[roleID] {
p.db.AssignRole(ctx, userID, roleID)
}
}
// Remove roles that are no longer in LDAP groups
// Only remove roles that are in the group mappings (don't remove manually assigned roles)
mappedRoles := make(map[string]bool)
for _, roleID := range p.config.GroupMappings {
mappedRoles[roleID] = true
}
for roleID := range currentRoleIDs {
if mappedRoles[roleID] && !targetRoleIDs[roleID] {
p.db.RemoveRole(ctx, userID, roleID)
}
}
}
// TestConnection tests the LDAP connection and bind.
func (p *LDAPProvider) TestConnection() error {
if !p.Available() {
return fmt.Errorf("LDAP not configured")
}
conn, err := p.connect()
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer conn.Close()
if err := conn.Bind(p.config.BindDN, p.config.BindPassword); err != nil {
return fmt.Errorf("bind: %w", err)
}
return nil
}