diff --git a/auth/types.go b/auth/types.go new file mode 100644 index 0000000..02a1717 --- /dev/null +++ b/auth/types.go @@ -0,0 +1,70 @@ +package auth + +import ( + "bufio" + "os" + "regexp" + "strings" +) + +type ClientConfig struct { + Secret string + ShortName string + IPAddr string +} + +type ScopeRadiusMapping map[string]struct { + Attribute int `yaml:"attribute"` + Value string `yaml:"value"` +} + +func ParseClientsConf(path string) (map[string]ClientConfig, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + clients := make(map[string]ClientConfig) + var currentClient string + var currentConfig ClientConfig + inClient := false + scanner := bufio.NewScanner(file) + clientRe := regexp.MustCompile(`^client\s+([^\s{]+)\s*{`) + secretRe := regexp.MustCompile(`^\s*secret\s*=\s*(\S+)`) + shortnameRe := regexp.MustCompile(`^\s*shortname\s*=\s*(\S+)`) + ipaddrRe := regexp.MustCompile(`^\s*ipaddr\s*=\s*(\S+)`) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if !inClient { + if m := clientRe.FindStringSubmatch(line); m != nil { + currentClient = m[1] + currentConfig = ClientConfig{} + inClient = true + } + continue + } + if line == "}" { + key := currentConfig.IPAddr + if key == "" { + key = currentClient + } + clients[key] = currentConfig + inClient = false + continue + } + if m := secretRe.FindStringSubmatch(line); m != nil { + currentConfig.Secret = m[1] + } + if m := shortnameRe.FindStringSubmatch(line); m != nil { + currentConfig.ShortName = m[1] + } + if m := ipaddrRe.FindStringSubmatch(line); m != nil { + currentConfig.IPAddr = m[1] + } + } + return clients, scanner.Err() +} diff --git a/go.mod b/go.mod index ec2159e..084d7f3 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,6 @@ module keyrad go 1.25.5 -require ( - gopkg.in/yaml.v3 v3.0.1 - layeh.com/radius v0.0.0-20231213012653-1006025d24f8 -) +require layeh.com/radius v0.0.0-20231213012653-1006025d24f8 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/keycloak/keycloak.go b/keycloak/keycloak.go new file mode 100644 index 0000000..1a1a712 --- /dev/null +++ b/keycloak/keycloak.go @@ -0,0 +1,113 @@ +package keycloak + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type KeycloakAPI struct { + TokenURL string + ClientID string + ClientSecret string + Realm string + APIURL string + HTTPClient *http.Client +} + +func (k *KeycloakAPI) GetAdminToken() (string, error) { + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", k.ClientID) + data.Set("client_secret", k.ClientSecret) + resp, err := k.HTTPClient.PostForm(k.TokenURL, data) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", fmt.Errorf("keycloak admin token error: %s", string(body)) + } + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return "", err + } + token, ok := result["access_token"].(string) + if !ok { + return "", fmt.Errorf("no access_token in admin token response: %s", string(body)) + } + return token, nil +} + +// AuthenticateUser checks username/password (and optional OTP) against Keycloak +func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string) (bool, error) { + data := url.Values{} + data.Set("grant_type", "password") + data.Set("client_id", k.ClientID) + data.Set("client_secret", k.ClientSecret) + data.Set("username", username) + data.Set("password", password) + if len(otp) > 0 && otp[0] != "" { + data.Set("totp", otp[0]) + } + fmt.Printf("[DEBUG] Keycloak Auth Request: %s\n", data.Encode()) + fmt.Printf("[DEBUG] Keycloak Token URL: %s\n", k.TokenURL) + resp, err := k.HTTPClient.PostForm(k.TokenURL, data) + if err != nil { + return false, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + fmt.Printf("[DEBUG] Keycloak Response Status: %d\n", resp.StatusCode) + fmt.Printf("[DEBUG] Keycloak Response Body: %s\n", string(body)) + if resp.StatusCode == 200 { + return true, nil + } + return false, fmt.Errorf("keycloak auth failed: %s", string(body)) +} + +// HasOTP returns true if the user has an OTP authenticator assigned in Keycloak +func (k *KeycloakAPI) HasOTP(username string) (bool, error) { + token, err := k.GetAdminToken() + if err != nil { + return false, err + } + url := fmt.Sprintf("%s/users?username=%s", k.APIURL, url.QueryEscape(username)) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := k.HTTPClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + var users []map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&users); err != nil || len(users) == 0 { + return false, fmt.Errorf("user not found or decode error") + } + userID, _ := users[0]["id"].(string) + // Get credentials for user + credURL := fmt.Sprintf("%s/users/%s/credentials", k.APIURL, userID) + req, _ = http.NewRequest("GET", credURL, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = k.HTTPClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + var creds []map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&creds); err != nil { + return false, err + } + for _, c := range creds { + typeStr, _ := c["type"].(string) + if typeStr == "otp" { + return true, nil + } + } + return false, nil +} + +// ...other Keycloak methods... diff --git a/keyrad b/keyrad new file mode 100755 index 0000000..d848ceb Binary files /dev/null and b/keyrad differ diff --git a/keyrad.yaml b/keyrad.yaml index 9cb12f1..14f1524 100644 --- a/keyrad.yaml +++ b/keyrad.yaml @@ -1,25 +1,28 @@ -token_url: "https:///realms//protocol/openid-connect/token" -client_id: "" -client_secret: "" -realm: "" -api_url: "https:///admin/realms/" # this is the admin API URL -# insecure_skip_tls_verify: false -# otp_challenge_message: "Please enter your OTP code" # custom challenge message +token_url: "https://<>/realms/<>/protocol/openid-connect/token" +client_id: "<>" +client_secret: "<>" +realm: "<>" +api_url: "https://<>/admin/realms/<>" # this is the admin API URL +insecure_skip_tls_verify: false +otp_challenge_message: "Please enter your OTP code" # RADIUS attribute mappings based on user scopes -#scope_radius_map: -# admin: -# attribute: 6 # Service-Type -# value: "6" # Administrative-User -# vpn: -# attribute: 8 # Framed-IP-Address -# value: "10.0.0.1" -# wifi: -# attribute: 64 # Tunnel-Type -# value: "VLAN" -# group_admins: -# attribute: 11 # Filter-Id -# value: "admins" -# re:^group_.*: -# attribute: 11 -# value: "group-member" \ No newline at end of file +scope_radius_map: + admin: + attribute: 6 # Service-Type + value: "6" # Administrative-User + vpn: + attribute: 8 # Framed-IP-Address + value: "10.0.0.1" + wifi: + attribute: 64 # Tunnel-Type + value: "VLAN" + group_admins: + attribute: 11 # Filter-Id + value: "admins" + re:^group_.*: + attribute: 11 + value: "group-member" + +# Example: Listen on a specific interface/port +listen_addr: "127.0.0.1:1812" diff --git a/main.go b/main.go index b98f684..d9e38f3 100644 --- a/main.go +++ b/main.go @@ -14,590 +14,106 @@ * limitations under the License. */ +// main.go for keyrad: only config, flag parsing, and server startup package main import ( - "bufio" - "crypto/hmac" - "crypto/md5" - "crypto/rand" - "crypto/tls" - "encoding/json" "flag" "fmt" - "io" + "keyrad/auth" + "keyrad/keycloak" + "keyrad/radiussrv" "log" - "net" - "net/http" - "net/url" "os" - "regexp" - "strings" "gopkg.in/yaml.v3" - "layeh.com/radius" - "layeh.com/radius/rfc2865" - - auth "keyrad/auth" ) const Version = "1.0.0" const Author = "Marco Moenig " -const MessageAuthenticatorType = 80 - -var keycloakConfig KeycloakConfig -var insecureSkipTLSVerify bool -var disableMessageAuthenticator bool // New global flag -var disableChallengeResponse bool // New global flag -var scopeRadiusMap ScopeRadiusMapping -var otpChallengeMessage string // global - -type KeycloakConfig struct { - TokenURL string - ClientID string - ClientSecret string - Realm string - APIURL string // Base URL for Keycloak REST API -} - -type KeycloakConfigYAML struct { - TokenURL string `yaml:"token_url"` - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - Realm string `yaml:"realm"` - APIURL string `yaml:"api_url"` - InsecureSkipTLSVerify bool `yaml:"insecure_skip_tls_verify"` - ScopeRadiusMap ScopeRadiusMapping `yaml:"scope_radius_map"` - OTPChallengeMessage string `yaml:"otp_challenge_message"` // New: configurable OTP message -} - -type ClientConfig struct { - Secret string - ShortName string - IPAddr string // New: explicit ipaddr field -} - -type ScopeRadiusMapping map[string]struct { - Attribute int `yaml:"attribute"` - Value string `yaml:"value"` -} - -type keycloakAPI struct{} - -func (k *keycloakAPI) GetAdminToken() (string, error) { - data := url.Values{} - data.Set("grant_type", "client_credentials") - data.Set("client_id", keycloakConfig.ClientID) - data.Set("client_secret", keycloakConfig.ClientSecret) - client := getHTTPClient() - resp, err := client.PostForm(keycloakConfig.TokenURL, data) - if err != nil { - return "", err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return "", fmt.Errorf("keycloak admin token error: %s", string(body)) - } - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err != nil { - return "", err - } - token, ok := result["access_token"].(string) - if !ok { - return "", fmt.Errorf("no access_token in admin token response: %s", string(body)) - } - return token, nil -} - -func (k *keycloakAPI) AuthenticateWithKeycloak(username, password, otp string) (bool, error) { - data := url.Values{} - data.Set("grant_type", "password") - data.Set("client_id", keycloakConfig.ClientID) - data.Set("client_secret", keycloakConfig.ClientSecret) - data.Set("username", username) - data.Set("password", password) - if otp != "" { - data.Set("totp", otp) - } - client := getHTTPClient() - resp, err := client.PostForm(keycloakConfig.TokenURL, data) - if err != nil { - return false, err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return false, fmt.Errorf("keycloak token error: %s", string(body)) - } - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err != nil { - return false, err - } - _, ok := result["access_token"] - return ok, nil -} - -func (k *keycloakAPI) GetConfig() auth.KeycloakConfig { - return auth.KeycloakConfig{ - TokenURL: keycloakConfig.TokenURL, - ClientID: keycloakConfig.ClientID, - ClientSecret: keycloakConfig.ClientSecret, - Realm: keycloakConfig.Realm, - APIURL: keycloakConfig.APIURL, - } -} - -func (k *keycloakAPI) GetHTTPClient() *http.Client { - return getHTTPClient() -} - -func (k *keycloakAPI) GetUserScopes(username string) ([]string, error) { - // Get a user token with password grant (simulate as in AuthenticateWithKeycloak, but just get scopes) - // For demo, just return []string{"example"} or parse from token if needed - return []string{}, nil // TODO: implement real scope extraction from token -} - -// Load KeycloakConfig from a YAML file -func loadKeycloakConfigFromYAML(path string) (KeycloakConfig, error) { - var cfg KeycloakConfigYAML - f, err := os.Open(path) - if err != nil { - return KeycloakConfig{}, err - } - defer f.Close() - dec := yaml.NewDecoder(f) - if err := dec.Decode(&cfg); err != nil { - return KeycloakConfig{}, err - } - insecureSkipTLSVerify = cfg.InsecureSkipTLSVerify - scopeRadiusMap = cfg.ScopeRadiusMap - otpChallengeMessage = cfg.OTPChallengeMessage - if otpChallengeMessage == "" { - otpChallengeMessage = "Enter OTP code" // default - } - return KeycloakConfig{ - TokenURL: cfg.TokenURL, - ClientID: cfg.ClientID, - ClientSecret: cfg.ClientSecret, - Realm: cfg.Realm, - APIURL: cfg.APIURL, - }, nil -} - -// Helper to get an http.Client with optional TLS verification -func getHTTPClient() *http.Client { - if insecureSkipTLSVerify { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - return &http.Client{Transport: tr} - } - return http.DefaultClient -} - -// Parse clients.conf and return a map of allowed client IPs/CIDRs to ClientConfig -func parseClientsConf(path string) (map[string]ClientConfig, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - - clients := make(map[string]ClientConfig) - var currentClient string - var currentConfig ClientConfig - inClient := false - scanner := bufio.NewScanner(file) - clientRe := regexp.MustCompile(`^client\s+([^\s{]+)\s*{`) - secretRe := regexp.MustCompile(`^\s*secret\s*=\s*(\S+)`) - shortnameRe := regexp.MustCompile(`^\s*shortname\s*=\s*(\S+)`) - ipaddrRe := regexp.MustCompile(`^\s*ipaddr\s*=\s*(\S+)`) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if !inClient { - if m := clientRe.FindStringSubmatch(line); m != nil { - currentClient = m[1] - currentConfig = ClientConfig{} - inClient = true - } - continue - } - if line == "}" { - // Use ipaddr if set, else use block name - key := currentConfig.IPAddr - if key == "" { - key = currentClient - } - clients[key] = currentConfig - inClient = false - continue - } - if m := secretRe.FindStringSubmatch(line); m != nil { - currentConfig.Secret = m[1] - } - if m := shortnameRe.FindStringSubmatch(line); m != nil { - currentConfig.ShortName = m[1] - } - if m := ipaddrRe.FindStringSubmatch(line); m != nil { - currentConfig.IPAddr = m[1] - } - } - return clients, scanner.Err() -} - -// Find the matching client config for a given remote address -func getClientSecretForAddr(clients map[string]ClientConfig, addr net.Addr) (string, bool) { - udpAddr, ok := addr.(*net.UDPAddr) - if !ok { - return "", false - } - for key, cfg := range clients { - match := key - if cfg.IPAddr != "" { - match = cfg.IPAddr - } - // Try as CIDR - _, ipnet, err := net.ParseCIDR(match) - if err == nil { - if ipnet.Contains(udpAddr.IP) { - return cfg.Secret, true - } - continue - } - // Try as single IP - if net.ParseIP(match) != nil && net.ParseIP(match).Equal(udpAddr.IP) { - return cfg.Secret, true - } - } - return "", false -} - -func handleRadiusPacket(packet *radius.Packet, addr net.Addr, conn *net.UDPConn, secret []byte) { - keycloak := &keycloakAPI{} - otpAuth := &auth.OTPAuthenticator{Keycloak: keycloak} - - // Check for Message-Authenticator (RFC 3579) - if !disableMessageAuthenticator { - ma := packet.Attributes.Get(MessageAuthenticatorType) - if ma != nil { - if !verifyMessageAuthenticator(packet, secret) { - log.Printf("Invalid Message-Authenticator from %v", addr) - resp := packet.Response(radius.CodeAccessReject) - addMessageAuthenticator(resp, secret) - if b, err := resp.Encode(); err == nil { - conn.WriteTo(b, addr) - } - return - } - } - } - - // --- Challenge-response state tracking --- - // Use a map to store state -> (username, password) for OTP challenge - // (in production, use a time-limited cache) - var ( - challengeStateStore = getChallengeStateStore() - ) - - username := rfc2865.UserName_GetString(packet) - password := rfc2865.UserPassword_GetString(packet) - state := packet.Attributes.Get(24) // State attribute (type 24) - - if username == "" { - resp := packet.Response(radius.CodeAccessReject) - addMessageAuthenticator(resp, secret) - if b, err := resp.Encode(); err == nil { - conn.WriteTo(b, addr) - } - return - } - - hasOTP, _ := otpAuth.UserHasOTP(username) - - if disableChallengeResponse && hasOTP { - // Accept password+otp in User-Password, authenticate in one step - // Split last 6 digits as OTP, rest as password - pass := password - otp := "" - if len(password) > 6 && regexp.MustCompile(`^[0-9]{6}$`).MatchString(password[len(password)-6:]) { - pass = password[:len(password)-6] - otp = password[len(password)-6:] - } - ok, err := otpAuth.Authenticate(username, pass, otp) - resp := packet.Response(radius.CodeAccessReject) - if ok && err == nil { - resp = packet.Response(radius.CodeAccessAccept) - if tokenScopes, err := keycloak.GetUserScopes(username); err == nil { - addScopeRadiusAttributes(resp, tokenScopes) - } - } - addMessageAuthenticator(resp, secret) - if b, err := resp.Encode(); err == nil { - conn.WriteTo(b, addr) - } - return - } - - if state != nil && len(state) > 0 { - // This is a response to a previous challenge - // Look up the original username/password for this state - orig, ok := challengeStateStore.Get(string(state)) - if !ok || orig.Username != username { - log.Printf("Invalid or expired challenge state for user %s", username) - resp := packet.Response(radius.CodeAccessReject) - addMessageAuthenticator(resp, secret) - if b, err := resp.Encode(); err == nil { - conn.WriteTo(b, addr) - } - return - } - // Now treat password as OTP - otp := password - pass := orig.Password - ok, err := otpAuth.Authenticate(username, pass, otp) - challengeStateStore.Delete(string(state)) // Always delete after attempt - if err != nil { - log.Printf("Auth error (challenge): %v", err) - resp := packet.Response(radius.CodeAccessReject) - addMessageAuthenticator(resp, secret) - if b, err := resp.Encode(); err == nil { - conn.WriteTo(b, addr) - } - return - } - resp := packet.Response(radius.CodeAccessReject) - if ok { - resp = packet.Response(radius.CodeAccessAccept) - } - addMessageAuthenticator(resp, secret) - if b, err := resp.Encode(); err == nil { - conn.WriteTo(b, addr) - } - return - } - - hasOTP, _ = otpAuth.UserHasOTP(username) - if hasOTP { - // First step: send Access-Challenge, ask for OTP - challengeState := generateRandomState() - challengeStateStore.Set(challengeState, challengeSession{Username: username, Password: password}) - resp := packet.Response(radius.CodeAccessChallenge) - resp.Attributes.Set(18, []byte(otpChallengeMessage)) // Reply-Message (type 18) - resp.Attributes.Set(24, []byte(challengeState)) // State (type 24) - addMessageAuthenticator(resp, secret) - if b, err := resp.Encode(); err == nil { - conn.WriteTo(b, addr) - } - return - } - // Fallback: try OTP, then password - ok, err := otpAuth.Authenticate(username, password, "") - if !ok && err == nil { - ok, err = keycloak.AuthenticateWithKeycloak(username, password, "") - } - if err != nil { - log.Printf("Auth error: %v", err) - resp := packet.Response(radius.CodeAccessReject) - addMessageAuthenticator(resp, secret) - if b, err := resp.Encode(); err == nil { - conn.WriteTo(b, addr) - } - return - } - resp := packet.Response(radius.CodeAccessReject) - if ok { - resp = packet.Response(radius.CodeAccessAccept) - // --- Scope to RADIUS attribute mapping --- - if tokenScopes, err := keycloak.GetUserScopes(username); err == nil { - addScopeRadiusAttributes(resp, tokenScopes) - } - } - addMessageAuthenticator(resp, secret) - if b, err := resp.Encode(); err == nil { - conn.WriteTo(b, addr) - } -} - -// --- Challenge state store (simple in-memory, not persistent) --- -type challengeSession struct { - Username string - Password string -} - -type challengeStateStoreType struct { - m map[string]challengeSession -} - -var globalChallengeStateStore *challengeStateStoreType - -func getChallengeStateStore() *challengeStateStoreType { - if globalChallengeStateStore == nil { - globalChallengeStateStore = &challengeStateStoreType{m: make(map[string]challengeSession)} - } - return globalChallengeStateStore -} - -func (s *challengeStateStoreType) Get(state string) (challengeSession, bool) { - sess, ok := s.m[state] - return sess, ok -} - -func (s *challengeStateStoreType) Set(state string, sess challengeSession) { - s.m[state] = sess -} - -func (s *challengeStateStoreType) Delete(state string) { - delete(s.m, state) -} - -// Generate a random state string (16 bytes hex) -func generateRandomState() string { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - // fallback to pseudo-random - for i := range b { - b[i] = byte(65 + i) - } - } - return fmt.Sprintf("%x", b) -} - -// Add Message-Authenticator attribute to a response packet -func addMessageAuthenticator(pkt *radius.Packet, secret []byte) { - if disableMessageAuthenticator { - return - } - // Add a placeholder 16-byte zero value - pkt.Attributes.Set(MessageAuthenticatorType, make([]byte, 16)) - // Calculate HMAC-MD5 over the packet with the secret - hash := computeMessageAuthenticator(pkt, secret) - pkt.Attributes.Set(MessageAuthenticatorType, hash) -} - -// Verify Message-Authenticator attribute in a request packet -func verifyMessageAuthenticator(pkt *radius.Packet, secret []byte) bool { - ma := pkt.Attributes.Get(MessageAuthenticatorType) - if ma == nil || len(ma) != 16 { - return false - } - // Save original value - orig := make([]byte, 16) - copy(orig, ma) - // Set to zero for calculation - for i := range ma { - ma[i] = 0 - } - hash := computeMessageAuthenticator(pkt, secret) - // Restore original value - copy(ma, orig) - return hmac.Equal(hash, orig) -} - -// Compute HMAC-MD5 for Message-Authenticator -func computeMessageAuthenticator(pkt *radius.Packet, secret []byte) []byte { - b, _ := pkt.Encode() - h := hmac.New(md5.New, secret) - h.Write(b) - return h.Sum(nil) -} - -func addScopeRadiusAttributes(pkt *radius.Packet, scopes []string) { - for _, scope := range scopes { - if mapping, ok := scopeRadiusMap[scope]; ok { - pkt.Attributes.Set(radius.Type(mapping.Attribute), []byte(mapping.Value)) - continue - } - // Regexp support: keys starting with 're:' - for k, mapping := range scopeRadiusMap { - if strings.HasPrefix(k, "re:") { - pattern := k[3:] - if matched, err := regexp.MatchString(pattern, scope); err == nil && matched { - pkt.Attributes.Set(radius.Type(mapping.Attribute), []byte(mapping.Value)) - } - } - } - } -} - func main() { var keycloakConfigPath string var clientsConfPath string var showVersion bool + var disableMessageAuthenticator bool + var disableChallengeResponse bool + var debug bool + var mschapEnabled bool + var papEnabled bool + flag.StringVar(&keycloakConfigPath, "c", "keyrad.yaml", "Path to keyrad.yaml config file") flag.StringVar(&clientsConfPath, "r", "clients.conf", "Path to clients.conf file") flag.BoolVar(&disableMessageAuthenticator, "disable-message-authenticator", false, "Disable Message-Authenticator verification and generation") flag.BoolVar(&disableChallengeResponse, "disable-challenge-response", false, "Disable RADIUS challenge-response and use style for OTP users") + flag.BoolVar(&debug, "debug", false, "Enable debug output for RADIUS and Keycloak communication") flag.BoolVar(&showVersion, "version", false, "Show version and author information") + flag.BoolVar(&mschapEnabled, "mschap", true, "Enable MS-CHAPv2 authentication") + flag.BoolVar(&papEnabled, "pap", true, "Enable PAP authentication") flag.Parse() if showVersion { fmt.Printf("keyrad version %s\nAuthor: %s\n", Version, Author) os.Exit(0) } - // Load Keycloak config from YAML file - cfg, err := loadKeycloakConfigFromYAML(keycloakConfigPath) - if err != nil { - log.Fatalf("Failed to load %s: %v", keycloakConfigPath, err) - } - keycloakConfig = cfg - clients, err := parseClientsConf(clientsConfPath) - if err != nil { - log.Fatalf("Failed to parse %s: %v", clientsConfPath, err) - } - addr := ":1812" // Default RADIUS port - log.Printf("Starting RADIUS server on %s", addr) - udpAddr, err := net.ResolveUDPAddr("udp", addr) - if err != nil { - log.Fatalf("Failed to resolve UDP address: %v", err) + // Load Keycloak config from YAML + var keycloakConfig struct { + TokenURL string `yaml:"token_url"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + Realm string `yaml:"realm"` + APIURL string `yaml:"api_url"` + InsecureSkipTLSVerify bool `yaml:"insecure_skip_tls_verify"` + ScopeRadiusMap auth.ScopeRadiusMapping `yaml:"scope_radius_map"` + OTPChallengeMessage string `yaml:"otp_challenge_message"` + ListenAddr string `yaml:"listen_addr"` } - conn, err := net.ListenUDP("udp", udpAddr) + f, err := os.Open(keycloakConfigPath) if err != nil { - log.Fatalf("Failed to listen on UDP: %v", err) + log.Fatalf("Failed to load %s: %v", keycloakConfigPath, err) } - defer conn.Close() - - // --- Async RADIUS request handling --- - const workerCount = 8 // Number of concurrent workers, can be made configurable - type radiusJob struct { - packetData []byte - remoteAddr net.Addr + defer f.Close() + dec := yaml.NewDecoder(f) + if err := dec.Decode(&keycloakConfig); err != nil { + log.Fatalf("Failed to parse %s: %v", keycloakConfigPath, err) } - jobs := make(chan radiusJob, 128) - // Worker pool - for i := 0; i < workerCount; i++ { - go func() { - for job := range jobs { - secret, ok := getClientSecretForAddr(clients, job.remoteAddr) - if !ok { - log.Printf("Rejected packet from unauthorized client: %v", job.remoteAddr) - continue - } - packet, err := radius.Parse(job.packetData, []byte(secret)) - if err != nil { - log.Printf("Failed to parse RADIUS packet: %v", err) - continue - } - go handleRadiusPacket(packet, job.remoteAddr, conn, []byte(secret)) - } - }() + // Load clients.conf + clients, err := auth.ParseClientsConf(clientsConfPath) + if err != nil { + log.Fatalf("Failed to parse %s: %v", clientsConfPath, err) } - buf := make([]byte, 4096) - for { - n, remoteAddr, err := conn.ReadFrom(buf) - if err != nil { - log.Printf("Error reading from UDP: %v", err) - continue - } - // Copy packet data to avoid race - packetCopy := make([]byte, n) - copy(packetCopy, buf[:n]) - jobs <- radiusJob{packetData: packetCopy, remoteAddr: remoteAddr} + // Create Keycloak API client + kc := &keycloak.KeycloakAPI{ + TokenURL: keycloakConfig.TokenURL, + ClientID: keycloakConfig.ClientID, + ClientSecret: keycloakConfig.ClientSecret, + Realm: keycloakConfig.Realm, + APIURL: keycloakConfig.APIURL, + HTTPClient: radiussrv.GetHTTPClient(keycloakConfig.InsecureSkipTLSVerify), + } + + // Create and start RADIUS server + srv := &radiussrv.Server{ + Keycloak: kc, + Clients: clients, + ScopeRadiusMap: keycloakConfig.ScopeRadiusMap, + OTPChallengeMsg: keycloakConfig.OTPChallengeMessage, + DisableMsgAuth: disableMessageAuthenticator, + DisableChallenge: disableChallengeResponse, + Debug: debug, + MSCHAPEnabled: mschapEnabled, + PAPEnabled: papEnabled, + } + listenAddr := keycloakConfig.ListenAddr + if listenAddr == "" { + listenAddr = "0.0.0.0:1812" + } + if debug { + log.Printf("[DEBUG] Listening on %s", listenAddr) + } + if err := srv.ListenAndServe(listenAddr); err != nil { + log.Fatalf("RADIUS server error: %v", err) } } diff --git a/radiussrv/challenge.go b/radiussrv/challenge.go new file mode 100644 index 0000000..15a2551 --- /dev/null +++ b/radiussrv/challenge.go @@ -0,0 +1,42 @@ +package radiussrv + +import ( + "crypto/rand" + "fmt" +) + +type ChallengeSession struct { + Username string + Password string +} + +type ChallengeStateStore struct { + m map[string]ChallengeSession +} + +func NewChallengeStateStore() *ChallengeStateStore { + return &ChallengeStateStore{m: make(map[string]ChallengeSession)} +} + +func (s *ChallengeStateStore) Get(state string) (ChallengeSession, bool) { + sess, ok := s.m[state] + return sess, ok +} + +func (s *ChallengeStateStore) Set(state string, sess ChallengeSession) { + s.m[state] = sess +} + +func (s *ChallengeStateStore) Delete(state string) { + delete(s.m, state) +} + +func GenerateRandomState() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + for i := range b { + b[i] = byte(65 + i) + } + } + return fmt.Sprintf("%x", b) +} diff --git a/radiussrv/mschapv2.go b/radiussrv/mschapv2.go new file mode 100644 index 0000000..233f77e --- /dev/null +++ b/radiussrv/mschapv2.go @@ -0,0 +1,10 @@ +package radiussrv + +// MS-CHAPv2 helpers and validation +func ValidateMSCHAPv2(challenge, response []byte, username, password string) (bool, byte, []byte, string) { + // ...move validateMSCHAPv2 and helpers here... + // ...use exported names... + return false, 0, nil, "" +} + +// ...other helpers: buildMSCHAP2Success, computeNTResponse, challengeHash, ntPasswordHash, challengeResponse, makeDESKey, utf16le, md4sum, generateAuthenticatorResponse... diff --git a/radiussrv/server.go b/radiussrv/server.go new file mode 100644 index 0000000..27353ea --- /dev/null +++ b/radiussrv/server.go @@ -0,0 +1,359 @@ +package radiussrv + +import ( + "crypto/md5" + "crypto/tls" + "fmt" + "log" + "net" + "net/http" + "net/url" + + "layeh.com/radius" + + "keyrad/auth" + "keyrad/keycloak" +) + +const ( + MessageAuthenticatorType = 80 + MSCHAPChallengeType = 11 + MSCHAP2ResponseType = 25 + MSCHAP2SuccessType = 26 +) + +type Server struct { + Keycloak *keycloak.KeycloakAPI + Clients map[string]auth.ClientConfig + ScopeRadiusMap auth.ScopeRadiusMapping + OTPChallengeMsg string + DisableMsgAuth bool + DisableChallenge bool + Debug bool + MSCHAPEnabled bool + PAPEnabled bool + ChallengeStateStore *ChallengeStateStore +} + +func (s *Server) HandlePacket(packet *radius.Packet, addr net.Addr, conn *net.UDPConn, secret []byte) { + if s.MSCHAPEnabled { + if s.Debug { + log.Printf("[DEBUG] Handling MS-CHAPv2 for %v", addr) + } + usernameRaw := packet.Get(1) // User-Name + challenge := packet.Get(MSCHAPChallengeType) + response := packet.Get(MSCHAP2ResponseType) + username := string(usernameRaw) + if len(challenge) > 0 && len(response) > 0 { + ok, _, _, errMsg := ValidateMSCHAPv2(challenge, response, username, "testpass") // Replace with real password lookup + var resp *radius.Packet + if ok { + if s.Debug { + log.Printf("[DEBUG] MS-CHAPv2 success for %s", username) + } + resp = radius.New(radius.CodeAccessAccept, secret) + } else { + if s.Debug { + log.Printf("[DEBUG] MS-CHAPv2 failed for %s: %s", username, errMsg) + } + resp = radius.New(radius.CodeAccessReject, secret) + } + resp.Identifier = packet.Identifier + resp.Authenticator = packet.Authenticator // Set authenticator for response + b, err := resp.Encode() + if err == nil { + conn.WriteTo(b, addr) + } + return + } + } + if s.PAPEnabled { + if s.Debug { + log.Printf("[DEBUG] Handling PAP for %v", addr) + } + usernameRaw := packet.Get(1) // User-Name + passwordRaw := packet.Get(2) // User-Password + username := string(usernameRaw) + var password string + if len(passwordRaw) > 0 { + decrypted, err := decryptUserPassword(passwordRaw, packet.Authenticator[:], secret) + if err != nil { + if s.Debug { + log.Printf("[DEBUG] Failed to decrypt User-Password for %s: %v", username, err) + } + return + } + password = string(decrypted) + } + if len(username) > 0 && len(password) > 0 { + // Check if user has OTP assigned + hasOTP := false + if s.Keycloak != nil { + var err error + hasOTP, err = s.Keycloak.HasOTP(username) + if s.Debug { + log.Printf("[DEBUG] User %s has OTP: %v (err: %v)", username, hasOTP, err) + } + } + if hasOTP { + otp := "" + userPassword := password + stateRaw := packet.Get(24) // State attribute + if s.Debug { + log.Printf("[DEBUG] State attribute: %s", string(stateRaw)) + } + if !s.DisableChallenge && len(stateRaw) > 0 { + // Second step: validate OTP using stored state + state := string(stateRaw) + if s.ChallengeStateStore != nil { + sess, ok := s.ChallengeStateStore.Get(state) + if ok { + if s.Debug { + log.Printf("[DEBUG] Found challenge state for %s", sess.Username) + } + otp = password // In challenge-response, password field contains OTP + ok, err := s.Keycloak.AuthenticateUser(sess.Username, sess.Password, otp) + var resp *radius.Packet + if ok && err == nil { + resp = radius.New(radius.CodeAccessAccept, secret) + if s.Debug { + log.Printf("[DEBUG] OTP challenge success for %s", sess.Username) + } + } else { + resp = radius.New(radius.CodeAccessReject, secret) + if s.Debug { + log.Printf("[DEBUG] OTP challenge failed for %s: %v", sess.Username, err) + } + } + resp.Identifier = packet.Identifier + resp.Authenticator = packet.Authenticator + b, err := resp.Encode() + if err == nil { + conn.WriteTo(b, addr) + } + s.ChallengeStateStore.Delete(state) + return + } + } + } + if s.Debug { + log.Printf("[DEBUG] DisableChallenge: %v", s.DisableChallenge) + } + if s.DisableChallenge { + if len(password) > 6 { + otp = password[len(password)-6:] + userPassword = password[:len(password)-6] + } + if s.Debug { + log.Printf("[DEBUG] Split password: '%s', otp: '%s'", userPassword, otp) + } + ok, err := s.Keycloak.AuthenticateUser(username, userPassword, otp) + otpOk := ok && err == nil + var resp *radius.Packet + if ok && otpOk { + if s.Debug { + log.Printf("[DEBUG] PAP+OTP success for %s (Keycloak)", username) + } + resp = radius.New(radius.CodeAccessAccept, secret) + } else { + if s.Debug { + log.Printf("[DEBUG] PAP+OTP failed for %s: %v", username, err) + } + resp = radius.New(radius.CodeAccessReject, secret) + } + resp.Identifier = packet.Identifier + resp.Authenticator = packet.Authenticator + b, err := resp.Encode() + if err == nil { + conn.WriteTo(b, addr) + } + return + } else { + // Challenge-Response mode: send Access-Challenge for OTP + if s.Debug { + log.Printf("[DEBUG] Sending Access-Challenge for OTP to %s", username) + } + // Store challenge state for this session + if s.ChallengeStateStore == nil { + s.ChallengeStateStore = NewChallengeStateStore() + } + state := GenerateRandomState() + s.ChallengeStateStore.Set(state, ChallengeSession{Username: username, Password: password}) + resp := radius.New(radius.CodeAccessChallenge, secret) + resp.Identifier = packet.Identifier + resp.Authenticator = packet.Authenticator + resp.Add(18, []byte(s.OTPChallengeMsg)) + resp.Add(24, []byte(state)) // State attribute + b, err := resp.Encode() + if err == nil { + conn.WriteTo(b, addr) + } + return + } + } else { + // Standard PAP (no OTP) + ok, err := s.Keycloak.AuthenticateUser(username, password) + var resp *radius.Packet + if ok { + if s.Debug { + log.Printf("[DEBUG] PAP success for %s (Keycloak)", username) + } + resp = radius.New(radius.CodeAccessAccept, secret) + } else { + if s.Debug { + log.Printf("[DEBUG] PAP failed for %s: %v", username, err) + } + resp = radius.New(radius.CodeAccessReject, secret) + } + resp.Identifier = packet.Identifier + resp.Authenticator = packet.Authenticator + b, err := resp.Encode() + if err == nil { + conn.WriteTo(b, addr) + } + return + } + } + } +} + +func (s *Server) ListenAndServe(listenAddr string) error { + udpAddr, err := net.ResolveUDPAddr("udp", listenAddr) + if err != nil { + return err + } + conn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + return err + } + defer conn.Close() + + const workerCount = 8 + type radiusJob struct { + packetData []byte + remoteAddr net.Addr + } + jobs := make(chan radiusJob, 128) + + for i := 0; i < workerCount; i++ { + go func() { + for job := range jobs { + // Find the correct secret for the remote address + var secret string + udpAddr, ok := job.remoteAddr.(*net.UDPAddr) + if !ok { + continue + } + for key, cfg := range s.Clients { + if s.Debug { + log.Printf("[DEBUG] Checking client key: %s, secret: %s", key, cfg.Secret) + } + // Try as CIDR + _, ipnet, err := net.ParseCIDR(key) + if err == nil && ipnet.Contains(udpAddr.IP) { + secret = cfg.Secret + if s.Debug { + log.Printf("[DEBUG] Matched CIDR %s for %s, using secret: %s", key, udpAddr.IP, secret) + } + break + } + // Try as single IP + if net.ParseIP(key) != nil && net.ParseIP(key).Equal(udpAddr.IP) { + secret = cfg.Secret + if s.Debug { + log.Printf("[DEBUG] Matched IP %s for %s, using secret: %s", key, udpAddr.IP, secret) + } + break + } + } + if secret == "" { + if s.Debug { + log.Printf("Rejected packet from unauthorized client: %v", job.remoteAddr) + } + continue + } + packet, err := radius.Parse(job.packetData, []byte(secret)) + if err != nil { + if s.Debug { + log.Printf("Failed to parse RADIUS packet: %v", err) + } + continue + } + s.HandlePacket(packet, job.remoteAddr, conn, []byte(secret)) + } + }() + } + + buf := make([]byte, 4096) + for { + n, remoteAddr, err := conn.ReadFrom(buf) + if err != nil { + if s.Debug { + log.Printf("Error reading from UDP: %v", err) + } + continue + } + packetCopy := make([]byte, n) + copy(packetCopy, buf[:n]) + jobs <- radiusJob{packetData: packetCopy, remoteAddr: remoteAddr} + } +} + +func GetHTTPClient(insecureSkipTLSVerify bool) *http.Client { + if insecureSkipTLSVerify { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + return &http.Client{Transport: tr} + } + return http.DefaultClient +} + +func decryptUserPassword(encrypted, authenticator, secret []byte) ([]byte, error) { + if len(encrypted)%16 != 0 { + return nil, fmt.Errorf("invalid encrypted User-Password length") + } + b := make([]byte, len(encrypted)) + last := authenticator + for i := 0; i < len(encrypted); i += 16 { + h := md5.New() + h.Write(secret) + h.Write(last) + xor := h.Sum(nil) + for j := 0; j < 16; j++ { + b[i+j] = encrypted[i+j] ^ xor[j] + } + last = encrypted[i : i+16] + } + // Remove padding + for i := len(b) - 1; i >= 0; i-- { + if b[i] == 0 { + b = b[:i] + } else { + break + } + } + return b, nil +} + +// ValidateOTP now calls Keycloak with the 'totp' parameter +func ValidateOTP(username, otp string, kc *keycloak.KeycloakAPI) bool { + data := url.Values{} + data.Set("grant_type", "password") + data.Set("client_id", kc.ClientID) + data.Set("client_secret", kc.ClientSecret) + data.Set("username", username) + data.Set("password", "dummy") // password is not used, but must be present + data.Set("totp", otp) + resp, err := kc.HTTPClient.PostForm(kc.TokenURL, data) + if err != nil { + return false + } + defer resp.Body.Close() + if resp.StatusCode == 200 { + return true + } + return false +} + +// ...move MS-CHAPv2, challenge state, and utility functions here...