diff --git a/README.MD b/README.MD index 30fba57..004f016 100644 --- a/README.MD +++ b/README.MD @@ -1,6 +1,6 @@ # keyrad: Keycloak RADIUS Server -A Go-based RADIUS server that authenticates users against Keycloak, supporting password and OTP (TOTP) flows. +A Go-based RADIUS server that authenticates users against Keycloak, supporting password and OTP (TOTP) flows. Features include: - Challenge-response for OTP @@ -8,6 +8,7 @@ Features include: - FreeRADIUS-style `clients.conf` for client secrets - Optional Message-Authenticator - Scope/group-to-RADIUS attribute mapping (with regexp support) +- **Vendor-Specific Attributes (VSA):** Return vendor-specific attributes (e.g. MikroTik, Cisco, Ubiquiti) in Access-Accept responses based on user roles, groups, or scopes - **Configurable OTP mode:** Use standard challenge-response or style - **Configurable OTP challenge message** via `otp_challenge_message` in config - **Asynchronous/multi-request support:** Multiple RADIUS requests are handled concurrently for high performance @@ -41,11 +42,78 @@ go build -o keyrad main.go - Users must have credentials set for password or OTP (TOTP) as desired. - To use OTP, users must enroll an OTP device in Keycloak. -### Configuration -See `clients.conf` and `keyrad.yaml`. +### 2. keyrad.yaml +See `keyrad.yaml` for a full example. - To customize the OTP challenge message, add `otp_challenge_message: "Your custom message"` to `keyrad.yaml`. -- The server now supports multiple concurrent RADIUS requests for high performance (async worker pool, default 8 workers). +- The server supports multiple concurrent RADIUS requests (async worker pool, default 8 workers). + +### 3. clients.conf +FreeRADIUS-style client definitions. See `clients.conf` for the format. + +### 4. Scope-to-RADIUS Attribute Mapping + +Map Keycloak realm roles, groups, or OAuth2 scopes to RADIUS attributes returned in Access-Accept responses. Supports both standard RADIUS attributes and Vendor-Specific Attributes (VSA). + +After successful authentication, keyrad extracts the user's roles, groups, and scopes from the Keycloak JWT access token and matches them against the `scope_radius_map` keys. + +#### Configuration format + +```yaml +scope_radius_map: + # Exact match against role/group/scope name + admin: + - attribute: 6 # Service-Type + value: "6" # Administrative-User + value_type: integer + + # Multiple attributes per scope + vpn: + - attribute: 8 # Framed-IP-Address + value: "10.0.0.1" + value_type: ipaddr + - vendor: 14988 # MikroTik Vendor ID + attribute: 3 # Mikrotik-Group + value: "full" + + # Vendor-Specific Attributes (VSA) + wifi: + - vendor: 14988 # MikroTik + attribute: 8 # Mikrotik-Rate-Limit + value: "10M/10M" + + # Regex matching with "re:" prefix + re:^group_.*: + - attribute: 11 # Filter-Id + value: "group-member" +``` + +#### Attribute fields + +| Field | Required | Description | +|--------------|----------|--------------------------------------------------------------------| +| `vendor` | No | IANA vendor ID. Omit or set to `0` for standard RADIUS attributes. | +| `attribute` | Yes | Attribute type number. | +| `value` | Yes | Attribute value. | +| `value_type` | No | Encoding type: `string` (default), `integer`, or `ipaddr`. | + +#### Common vendor IDs + +| Vendor | ID | Reference | +|----------|-------|--------------------------------------------------------------| +| Cisco | 9 | [Cisco VSA documentation](https://www.cisco.com) | +| Microsoft| 311 | RFC 2548 | +| MikroTik | 14988 | [MikroTik RADIUS dictionary](https://help.mikrotik.com/docs/spaces/ROS/pages/328097/RADIUS) | +| Ubiquiti | 41112 | Ubiquiti RADIUS dictionary | + +#### Keycloak setup for scope/group matching + +For roles and scopes, no extra Keycloak configuration is needed -- they are included in the JWT by default. + +For **group-based matching**, add a "groups" protocol mapper to your Keycloak client: +1. Go to your client's **Client Scopes** > **Dedicated scope** > **Add mapper** > **By configuration** > **Group Membership**. +2. Set **Token Claim Name** to `groups`. +3. Disable **Full group path** if you want short group names. ## Usage @@ -67,7 +135,8 @@ radtest testuser password 127.0.0.1 0 test ## Advanced Features - **Challenge-response**: Standard RADIUS Access-Challenge for OTP, or disable for legacy OTP style. - **Configurable OTP challenge message**: Set `otp_challenge_message` in your config for custom challenge text. -- **Scope/group mapping**: Map Keycloak OAuth2 scopes or group memberships to RADIUS attributes. Supports regexp keys (e.g. `re:^group_.*`). +- **Scope/group mapping**: Map Keycloak realm roles, groups, or OAuth2 scopes to RADIUS attributes. Supports regexp keys (e.g. `re:^group_.*`). +- **Vendor-Specific Attributes (VSA)**: Return vendor-specific attributes in Access-Accept based on user roles/groups. Supports any vendor by IANA vendor ID (e.g. MikroTik 14988, Cisco 9, Microsoft 311). - **TLS verification**: Set `insecure_skip_tls_verify: true` for self-signed Keycloak certs. - **Asynchronous/multi-request support**: Multiple RADIUS requests are handled concurrently for high performance. diff --git a/auth/types.go b/auth/types.go index 02a1717..f07e55b 100644 --- a/auth/types.go +++ b/auth/types.go @@ -13,11 +13,15 @@ type ClientConfig struct { IPAddr string } -type ScopeRadiusMapping map[string]struct { - Attribute int `yaml:"attribute"` - Value string `yaml:"value"` +type RadiusAttribute struct { + Vendor uint32 `yaml:"vendor"` // Vendor ID (0 = standard attribute) + Attribute int `yaml:"attribute"` // Attribute type number + Value string `yaml:"value"` // Attribute value + ValueType string `yaml:"value_type"` // "string" (default), "integer", "ipaddr" } +type ScopeRadiusMapping map[string][]RadiusAttribute + func ParseClientsConf(path string) (map[string]ClientConfig, error) { file, err := os.Open(path) if err != nil { diff --git a/keycloak/keycloak.go b/keycloak/keycloak.go index 1a1a712..9ce94c7 100644 --- a/keycloak/keycloak.go +++ b/keycloak/keycloak.go @@ -1,11 +1,13 @@ package keycloak import ( + "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" + "strings" ) type KeycloakAPI struct { @@ -29,7 +31,7 @@ func (k *KeycloakAPI) GetAdminToken() (string, error) { defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 { - return "", fmt.Errorf("keycloak admin token error: %s", string(body)) + return "", fmt.Errorf("keycloak admin token error: status %d", resp.StatusCode) } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { @@ -37,13 +39,15 @@ func (k *KeycloakAPI) GetAdminToken() (string, error) { } token, ok := result["access_token"].(string) if !ok { - return "", fmt.Errorf("no access_token in admin token response: %s", string(body)) + return "", fmt.Errorf("no access_token in admin token response") } return token, nil } -// AuthenticateUser checks username/password (and optional OTP) against Keycloak -func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string) (bool, error) { +// AuthenticateUser checks username/password (and optional OTP) against Keycloak. +// Returns (ok, userRoles, error) where userRoles are extracted from the JWT access token +// and include realm roles, groups, and OAuth2 scopes. +func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string) (bool, []string, error) { data := url.Values{} data.Set("grant_type", "password") data.Set("client_id", k.ClientID) @@ -53,20 +57,62 @@ func (k *KeycloakAPI) AuthenticateUser(username, password string, otp ...string) 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 Auth Request for user: %s\n", username) fmt.Printf("[DEBUG] Keycloak Token URL: %s\n", k.TokenURL) resp, err := k.HTTPClient.PostForm(k.TokenURL, data) if err != nil { - return false, err + return false, nil, 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 + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return false, nil, fmt.Errorf("failed to parse keycloak response: %w", err) + } + accessToken, _ := result["access_token"].(string) + roles := extractRolesFromJWT(accessToken) + return true, roles, nil } - return false, fmt.Errorf("keycloak auth failed: %s", string(body)) + return false, nil, fmt.Errorf("keycloak auth failed: status %d", resp.StatusCode) +} + +// extractRolesFromJWT decodes the JWT payload and extracts realm roles, groups, and scopes. +func extractRolesFromJWT(token string) []string { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil + } + payload := parts[1] + // Add base64 padding if needed + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + return nil + } + var claims struct { + RealmAccess struct { + Roles []string `json:"roles"` + } `json:"realm_access"` + Groups []string `json:"groups"` + Scope string `json:"scope"` + } + if err := json.Unmarshal(decoded, &claims); err != nil { + return nil + } + var roles []string + roles = append(roles, claims.RealmAccess.Roles...) + roles = append(roles, claims.Groups...) + if claims.Scope != "" { + roles = append(roles, strings.Split(claims.Scope, " ")...) + } + return roles } // HasOTP returns true if the user has an OTP authenticator assigned in Keycloak @@ -75,8 +121,11 @@ func (k *KeycloakAPI) HasOTP(username string) (bool, error) { if err != nil { return false, err } - url := fmt.Sprintf("%s/users?username=%s", k.APIURL, url.QueryEscape(username)) - req, _ := http.NewRequest("GET", url, nil) + reqURL := fmt.Sprintf("%s/users?username=%s", k.APIURL, url.QueryEscape(username)) + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create user lookup request: %w", err) + } req.Header.Set("Authorization", "Bearer "+token) resp, err := k.HTTPClient.Do(req) if err != nil { @@ -90,7 +139,10 @@ func (k *KeycloakAPI) HasOTP(username string) (bool, 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, err = http.NewRequest("GET", credURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create credentials request: %w", err) + } req.Header.Set("Authorization", "Bearer "+token) resp, err = k.HTTPClient.Do(req) if err != nil { diff --git a/keyrad b/keyrad index d848ceb..541e6c3 100755 Binary files a/keyrad and b/keyrad differ diff --git a/keyrad.yaml b/keyrad.yaml index 14f1524..76185b5 100644 --- a/keyrad.yaml +++ b/keyrad.yaml @@ -6,23 +6,41 @@ 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 +# RADIUS attribute mappings based on user roles/groups/scopes. +# Each scope key maps to a list of attributes (standard or vendor-specific). +# Scope keys are matched against Keycloak realm roles, groups, and OAuth2 scopes. +# Use "re:" prefix for regex matching (e.g. "re:^group_.*"). +# +# Attribute fields: +# vendor: Vendor ID (omit or 0 for standard attributes) +# attribute: Attribute type number +# value: Attribute value +# value_type: "string" (default), "integer", or "ipaddr" scope_radius_map: admin: - attribute: 6 # Service-Type - value: "6" # Administrative-User + - attribute: 6 # Service-Type + value: "6" # Administrative-User + value_type: integer vpn: - attribute: 8 # Framed-IP-Address - value: "10.0.0.1" + - attribute: 8 # Framed-IP-Address + value: "10.0.0.1" + value_type: ipaddr + - vendor: 14988 # MikroTik + attribute: 3 # Mikrotik-Group + value: "full" wifi: - attribute: 64 # Tunnel-Type - value: "VLAN" + - attribute: 64 # Tunnel-Type + value: "13" # VLAN + value_type: integer + - vendor: 14988 # MikroTik + attribute: 8 # Mikrotik-Rate-Limit + value: "10M/10M" group_admins: - attribute: 11 # Filter-Id - value: "admins" + - attribute: 11 # Filter-Id + value: "admins" re:^group_.*: - attribute: 11 - value: "group-member" + - attribute: 11 # Filter-Id + 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 d9e38f3..00e669a 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,7 @@ import ( "gopkg.in/yaml.v3" ) -const Version = "1.0.0" +const Version = "1.1.0dev1" const Author = "Marco Moenig " func main() { @@ -39,7 +39,6 @@ func main() { 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") @@ -48,8 +47,7 @@ func main() { 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.BoolVar(&papEnabled, "pap", true, "Enable PAP authentication") flag.Parse() if showVersion { fmt.Printf("keyrad version %s\nAuthor: %s\n", Version, Author) @@ -103,7 +101,6 @@ func main() { DisableMsgAuth: disableMessageAuthenticator, DisableChallenge: disableChallengeResponse, Debug: debug, - MSCHAPEnabled: mschapEnabled, PAPEnabled: papEnabled, } listenAddr := keycloakConfig.ListenAddr diff --git a/radiussrv/challenge.go b/radiussrv/challenge.go index 15a2551..cd61dec 100644 --- a/radiussrv/challenge.go +++ b/radiussrv/challenge.go @@ -3,6 +3,7 @@ package radiussrv import ( "crypto/rand" "fmt" + "sync" ) type ChallengeSession struct { @@ -11,7 +12,8 @@ type ChallengeSession struct { } type ChallengeStateStore struct { - m map[string]ChallengeSession + mu sync.RWMutex + m map[string]ChallengeSession } func NewChallengeStateStore() *ChallengeStateStore { @@ -19,24 +21,28 @@ func NewChallengeStateStore() *ChallengeStateStore { } func (s *ChallengeStateStore) Get(state string) (ChallengeSession, bool) { + s.mu.RLock() + defer s.mu.RUnlock() sess, ok := s.m[state] return sess, ok } func (s *ChallengeStateStore) Set(state string, sess ChallengeSession) { + s.mu.Lock() + defer s.mu.Unlock() s.m[state] = sess } func (s *ChallengeStateStore) Delete(state string) { + s.mu.Lock() + defer s.mu.Unlock() delete(s.m, state) } -func GenerateRandomState() string { +func GenerateRandomState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { - for i := range b { - b[i] = byte(65 + i) - } + return "", fmt.Errorf("failed to generate random state: %w", err) } - return fmt.Sprintf("%x", b) + return fmt.Sprintf("%x", b), nil } diff --git a/radiussrv/mschapv2.go b/radiussrv/mschapv2.go deleted file mode 100644 index 233f77e..0000000 --- a/radiussrv/mschapv2.go +++ /dev/null @@ -1,10 +0,0 @@ -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 index 27353ea..7ac3557 100644 --- a/radiussrv/server.go +++ b/radiussrv/server.go @@ -3,11 +3,15 @@ package radiussrv import ( "crypto/md5" "crypto/tls" + "encoding/binary" "fmt" "log" "net" "net/http" "net/url" + "regexp" + "strconv" + "strings" "layeh.com/radius" @@ -17,9 +21,6 @@ import ( const ( MessageAuthenticatorType = 80 - MSCHAPChallengeType = 11 - MSCHAP2ResponseType = 25 - MSCHAP2SuccessType = 26 ) type Server struct { @@ -30,43 +31,97 @@ type Server struct { 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) +// addScopeAttributes adds RADIUS attributes (standard and vendor-specific) to the +// response packet based on the user's roles/groups/scopes matched against the scope_radius_map. +func (s *Server) addScopeAttributes(resp *radius.Packet, userRoles []string) { + if s.ScopeRadiusMap == nil || len(userRoles) == 0 { + return + } + for scopeKey, attrs := range s.ScopeRadiusMap { + matched := false + if strings.HasPrefix(scopeKey, "re:") { + pattern := strings.TrimPrefix(scopeKey, "re:") + re, err := regexp.Compile(pattern) + if err != nil { + if s.Debug { + log.Printf("[DEBUG] Invalid regex in scope_radius_map: %s: %v", pattern, err) + } + continue + } + for _, role := range userRoles { + if re.MatchString(role) { + matched = true + break + } + } + } else { + for _, role := range userRoles { + if role == scopeKey { + matched = true + break + } + } } - 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 !matched { + continue + } + for _, attr := range attrs { + value := encodeAttributeValue(attr.Value, attr.ValueType) + if attr.Vendor > 0 { + // Vendor-Specific Attribute: encode as sub-attribute TLV + subAttr := make([]byte, 2+len(value)) + subAttr[0] = byte(attr.Attribute) + subAttr[1] = byte(2 + len(value)) + copy(subAttr[2:], value) + vsa, err := radius.NewVendorSpecific(attr.Vendor, radius.Attribute(subAttr)) + if err != nil { + if s.Debug { + log.Printf("[DEBUG] Failed to encode VSA vendor=%d attr=%d: %v", attr.Vendor, attr.Attribute, err) + } + continue + } + resp.Add(26, vsa) if s.Debug { - log.Printf("[DEBUG] MS-CHAPv2 success for %s", username) + log.Printf("[DEBUG] Added VSA: vendor=%d, attribute=%d, value=%s", attr.Vendor, attr.Attribute, attr.Value) } - resp = radius.New(radius.CodeAccessAccept, secret) } else { + resp.Add(radius.Type(attr.Attribute), value) if s.Debug { - log.Printf("[DEBUG] MS-CHAPv2 failed for %s: %s", username, errMsg) + log.Printf("[DEBUG] Added attribute: type=%d, value=%s", attr.Attribute, attr.Value) } - 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) + } + } +} + +func encodeAttributeValue(value, valueType string) []byte { + switch valueType { + case "integer": + n, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return []byte(value) + } + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, uint32(n)) + return b + case "ipaddr": + ip := net.ParseIP(value) + if ip != nil { + if ip4 := ip.To4(); ip4 != nil { + return []byte(ip4) } - return } + return []byte(value) + default: + return []byte(value) } +} + +func (s *Server) HandlePacket(packet *radius.Packet, addr net.Addr, conn *net.UDPConn, secret []byte) { if s.PAPEnabled { if s.Debug { log.Printf("[DEBUG] Handling PAP for %v", addr) @@ -112,12 +167,13 @@ func (s *Server) HandlePacket(packet *radius.Packet, addr net.Addr, conn *net.UD 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) + ok, roles, err := s.Keycloak.AuthenticateUser(sess.Username, sess.Password, otp) var resp *radius.Packet if ok && err == nil { resp = radius.New(radius.CodeAccessAccept, secret) + s.addScopeAttributes(resp, roles) if s.Debug { - log.Printf("[DEBUG] OTP challenge success for %s", sess.Username) + log.Printf("[DEBUG] OTP challenge success for %s (roles: %v)", sess.Username, roles) } } else { resp = radius.New(radius.CodeAccessReject, secret) @@ -140,21 +196,33 @@ func (s *Server) HandlePacket(packet *radius.Packet, addr net.Addr, conn *net.UD 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 len(password) <= 6 { + if s.Debug { + log.Printf("[DEBUG] Password too short for OTP split for %s", username) + } + 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 } + otp = password[len(password)-6:] + userPassword = password[:len(password)-6] if s.Debug { - log.Printf("[DEBUG] Split password: '%s', otp: '%s'", userPassword, otp) + log.Printf("[DEBUG] Split password length: %d, otp length: %d", len(userPassword), len(otp)) } - ok, err := s.Keycloak.AuthenticateUser(username, userPassword, otp) + ok, roles, 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) + log.Printf("[DEBUG] PAP+OTP success for %s (Keycloak, roles: %v)", username, roles) } resp = radius.New(radius.CodeAccessAccept, secret) + s.addScopeAttributes(resp, roles) } else { if s.Debug { log.Printf("[DEBUG] PAP+OTP failed for %s: %v", username, err) @@ -177,7 +245,11 @@ func (s *Server) HandlePacket(packet *radius.Packet, addr net.Addr, conn *net.UD if s.ChallengeStateStore == nil { s.ChallengeStateStore = NewChallengeStateStore() } - state := GenerateRandomState() + state, err := GenerateRandomState() + if err != nil { + log.Printf("Failed to generate challenge state: %v", err) + return + } s.ChallengeStateStore.Set(state, ChallengeSession{Username: username, Password: password}) resp := radius.New(radius.CodeAccessChallenge, secret) resp.Identifier = packet.Identifier @@ -192,13 +264,14 @@ func (s *Server) HandlePacket(packet *radius.Packet, addr net.Addr, conn *net.UD } } else { // Standard PAP (no OTP) - ok, err := s.Keycloak.AuthenticateUser(username, password) + ok, roles, err := s.Keycloak.AuthenticateUser(username, password) var resp *radius.Packet if ok { if s.Debug { - log.Printf("[DEBUG] PAP success for %s (Keycloak)", username) + log.Printf("[DEBUG] PAP success for %s (Keycloak, roles: %v)", username, roles) } resp = radius.New(radius.CodeAccessAccept, secret) + s.addScopeAttributes(resp, roles) } else { if s.Debug { log.Printf("[DEBUG] PAP failed for %s: %v", username, err) @@ -246,14 +319,14 @@ func (s *Server) ListenAndServe(listenAddr string) error { } for key, cfg := range s.Clients { if s.Debug { - log.Printf("[DEBUG] Checking client key: %s, secret: %s", key, cfg.Secret) + log.Printf("[DEBUG] Checking client key: %s", key) } // 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) + log.Printf("[DEBUG] Matched CIDR %s for %s", key, udpAddr.IP) } break } @@ -261,7 +334,7 @@ func (s *Server) ListenAndServe(listenAddr string) error { 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) + log.Printf("[DEBUG] Matched IP %s for %s", key, udpAddr.IP) } break } @@ -356,4 +429,3 @@ func ValidateOTP(username, otp string, kc *keycloak.KeycloakAPI) bool { return false } -// ...move MS-CHAPv2, challenge state, and utility functions here...