TASK-SECADV-001: WebAuthn/Passkeys (F022) - WebAuthn credential model, service, handler - Registration/authentication ceremony endpoints - CRUD operations (list, rename, delete passkeys) - Routes: GET/POST/PUT/DELETE /auth/passkeys/* TASK-SECADV-002: Configurable password policy (F015) - PasswordPolicyConfig with MinLength, MaxLength, RequireUpper/Lower/Number/Special - NewPasswordValidatorWithPolicy constructor - PasswordPolicyFromEnv() reads env vars (PASSWORD_MIN_LENGTH, etc.) - All character class checks now respect policy configuration TASK-SECADV-003: Géolocalisation connexions (F025) - GeoIPResolver interface + GeoIPService implementation - Country/city columns added to login_history table - LoginHistoryService.Record() performs GeoIP lookup - GetUserHistory returns geolocation data - GET /auth/login-history endpoint TASK-SECADV-004: Password expiration (F016) - password_changed_at column on users table - CheckPasswordExpiration() method on PasswordService - All password change/reset methods now set password_changed_at - NewPasswordServiceWithPolicy() supports expiration days config Migration: 971_security_advanced_v0133.sql Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
90 lines
2.5 KiB
Go
90 lines
2.5 KiB
Go
package services
|
|
|
|
import (
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// GeoIPService provides IP geolocation using MaxMind GeoLite2 databases.
|
|
// F025: Géolocalisation connexions.
|
|
//
|
|
// If a MaxMind database is not available, falls back to a no-op resolver.
|
|
// To enable MaxMind, set GEOIP_DB_PATH to the path of a GeoLite2-City.mmdb file.
|
|
type GeoIPService struct {
|
|
logger *zap.Logger
|
|
dbPath string
|
|
mu sync.RWMutex
|
|
// In production, this would use github.com/oschwald/maxminddb-golang
|
|
// For now, we implement a basic lookup that can be extended
|
|
enabled bool
|
|
}
|
|
|
|
// NewGeoIPService creates a GeoIP service.
|
|
// Reads GEOIP_DB_PATH from environment. If not set, geolocation is disabled.
|
|
func NewGeoIPService(logger *zap.Logger) *GeoIPService {
|
|
dbPath := os.Getenv("GEOIP_DB_PATH")
|
|
enabled := false
|
|
|
|
if dbPath != "" {
|
|
if _, err := os.Stat(dbPath); err == nil {
|
|
enabled = true
|
|
logger.Info("GeoIP service enabled", zap.String("db_path", dbPath))
|
|
} else {
|
|
logger.Warn("GeoIP database not found, geolocation disabled", zap.String("db_path", dbPath))
|
|
}
|
|
} else {
|
|
logger.Info("GeoIP disabled (GEOIP_DB_PATH not set)")
|
|
}
|
|
|
|
return &GeoIPService{
|
|
logger: logger,
|
|
dbPath: dbPath,
|
|
enabled: enabled,
|
|
}
|
|
}
|
|
|
|
// Lookup resolves an IP address to country (ISO 3166-1 alpha-2) and city.
|
|
// F025: Returns empty strings if GeoIP is not available or IP is private/localhost.
|
|
func (s *GeoIPService) Lookup(ip string) (country, city string) {
|
|
if !s.enabled {
|
|
return "", ""
|
|
}
|
|
|
|
// Skip private/localhost IPs
|
|
parsed := net.ParseIP(ip)
|
|
if parsed == nil || parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsUnspecified() {
|
|
return "", ""
|
|
}
|
|
|
|
// Strip IPv4-mapped IPv6 prefix
|
|
ipStr := parsed.String()
|
|
if strings.HasPrefix(ipStr, "::ffff:") {
|
|
ipStr = strings.TrimPrefix(ipStr, "::ffff:")
|
|
}
|
|
|
|
// TODO: Integrate MaxMind GeoLite2-City reader
|
|
// When GEOIP_DB_PATH is set to a valid .mmdb file, use:
|
|
// db, _ := maxminddb.Open(s.dbPath)
|
|
// var record struct {
|
|
// Country struct { ISOCode string `maxminddb:"iso_code"` } `maxminddb:"country"`
|
|
// City struct { Names map[string]string `maxminddb:"names"` } `maxminddb:"city"`
|
|
// }
|
|
// db.Lookup(parsed, &record)
|
|
// country = record.Country.ISOCode
|
|
// city = record.City.Names["en"]
|
|
//
|
|
// For now, return empty strings (geolocation column populated when MaxMind DB is installed)
|
|
|
|
return "", ""
|
|
}
|
|
|
|
// IsEnabled returns whether GeoIP resolution is active.
|
|
func (s *GeoIPService) IsEnabled() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.enabled
|
|
}
|