package auth import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" "github.com/archivmail/internal/ldapauth" ldapcfg "github.com/archivmail/internal/ldapconfig" "github.com/archivmail/internal/userstore" ) // Session holds the claims extracted from a validated JWT. type Session struct { UserID int64 Username string Role string JTI string // unique JWT ID TenantID *int64 TenantSlug string } // Manager handles login, token issuance, validation, and logout. type Manager struct { store *userstore.Store ldapStore *ldapcfg.Store jwtSecret []byte } // New creates a new auth Manager. // ldapStore may be nil; in that case LDAP fallback is disabled. func New(store *userstore.Store, ldapStore *ldapcfg.Store, jwtSecret string) *Manager { return &Manager{ store: store, ldapStore: ldapStore, jwtSecret: []byte(jwtSecret), } } // Login verifies credentials and returns a signed JWT token. // It first attempts a local password check. If that fails and LDAP is // configured and enabled, it falls back to LDAP authentication. func (m *Manager) Login(username, password string) (string, *userstore.User, error) { // 1. Try local authentication first. user, err := m.store.VerifyPassword(username, password) if err == nil { return m.issueToken(user) } // 2. LDAP fallback when the store is wired and the config is enabled. if m.ldapStore != nil { cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background()) if ldapErr == nil && cfg != nil && cfg.Enabled { attrs, authErr := ldapauth.Authenticate(ldapauth.Config{ URL: cfg.URL, BindDN: cfg.BindDN, BindPassword: cfg.BindPassword, BaseDN: cfg.BaseDN, UserFilter: cfg.UserFilter, TLS: cfg.TLS, TLSSkipVerify: cfg.TLSSkipVerify, }, username, password) if authErr == nil { // Determine role: check group_mappings first, fall back to default_role. role := cfg.DefaultRole if role == "" { role = userstore.RoleUser } memberOf := attrs["memberOf"] if memberOf != "" { for _, gm := range cfg.GroupMappings { if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) { role = gm.Role break } } } email := attrs["mail"] if email == "" { email = username + "@ldap.local" } ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, nil) if upsertErr == nil { return m.issueToken(ldapUser) } } } } return "", nil, fmt.Errorf("auth: login: invalid credentials") } // issueToken signs a JWT for the given user and returns the token string. func (m *Manager) issueToken(user *userstore.User) (string, *userstore.User, error) { jti := generateJTI() now := time.Now() var tenantIDVal int64 if user.TenantID != nil { tenantIDVal = *user.TenantID } claims := jwt.MapClaims{ "sub": user.Username, "role": user.Role, "uid": user.ID, "jti": jti, "iat": now.Unix(), "exp": now.Add(8 * time.Hour).Unix(), "tenant_id": tenantIDVal, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString(m.jwtSecret) if err != nil { return "", nil, fmt.Errorf("auth: sign token: %w", err) } return signed, user, nil } // ValidateToken parses and validates the token, checking the blacklist. func (m *Manager) ValidateToken(tokenStr string) (*Session, error) { token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("auth: unexpected signing method: %v", t.Header["alg"]) } return m.jwtSecret, nil }) if err != nil { return nil, fmt.Errorf("auth: invalid token: %w", err) } if !token.Valid { return nil, errors.New("auth: token not valid") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, errors.New("auth: bad claims") } jti, _ := claims["jti"].(string) blacklisted, err := m.store.IsBlacklisted(jti) if err != nil { return nil, fmt.Errorf("auth: blacklist check: %w", err) } if blacklisted { return nil, errors.New("auth: token revoked") } username, _ := claims["sub"].(string) role, _ := claims["role"].(string) var userID int64 switch v := claims["uid"].(type) { case float64: userID = int64(v) case int64: userID = v } var tenantID *int64 switch v := claims["tenant_id"].(type) { case float64: if v != 0 { id := int64(v) tenantID = &id } case int64: if v != 0 { tenantID = &v } } return &Session{ UserID: userID, Username: username, Role: role, JTI: jti, TenantID: tenantID, }, nil } // Logout revokes the token by adding its JTI to the blacklist. func (m *Manager) Logout(tokenStr string) error { token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("auth: unexpected signing method") } return m.jwtSecret, nil }) if err != nil { return fmt.Errorf("auth: logout parse: %w", err) } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return errors.New("auth: bad claims on logout") } jti, _ := claims["jti"].(string) var exp time.Time switch v := claims["exp"].(type) { case float64: exp = time.Unix(int64(v), 0) case int64: exp = time.Unix(v, 0) default: exp = time.Now().Add(8 * time.Hour) } return m.store.BlacklistToken(jti, exp) } // HasRole returns true when userRole satisfies the required role level. // Hierarchy: superadmin > admin > domain_admin > auditor > user func HasRole(userRole, required string) bool { levels := map[string]int{ userstore.RoleUser: 1, userstore.RoleAuditor: 2, userstore.RoleDomainAdmin: 3, userstore.RoleAdmin: 4, userstore.RoleSuperAdmin: 5, } return levels[userRole] >= levels[required] } // containsGroup checks whether a comma-separated memberOf string contains groupDN // (case-insensitive substring match to handle varying DN formats). func containsGroup(memberOf, groupDN string) bool { for _, dn := range splitMemberOf(memberOf) { if dn == groupDN { return true } } return false } func splitMemberOf(s string) []string { var out []string for _, part := range splitComma(s) { part = trimSpace(part) if part != "" { out = append(out, part) } } return out } // splitComma splits on commas that are not part of DN attribute values. // For simplicity we split on ", " and "," and let callers match. func splitComma(s string) []string { // Groups returned from LDAP memberOf are newline-separated in the map // because we join with "," in ldapauth. Re-split here. parts := []string{} start := 0 for i := 0; i < len(s); i++ { if s[i] == ',' && (i == 0 || s[i-1] != '\\') { parts = append(parts, s[start:i]) start = i + 1 } } parts = append(parts, s[start:]) return parts } func trimSpace(s string) string { start, end := 0, len(s) for start < end && (s[start] == ' ' || s[start] == '\t') { start++ } for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { end-- } return s[start:end] } // generateJTI returns a cryptographically random identifier for a JWT. func generateJTI() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // fallback: should never happen on a healthy system return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b) }