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>
277 lines
7.7 KiB
Go
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
|
|
}
|