Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions auth/types.go
Original file line number Diff line number Diff line change
@@ -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()
}
7 changes: 3 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 113 additions & 0 deletions keycloak/keycloak.go
Original file line number Diff line number Diff line change
@@ -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...
Binary file added keyrad
Binary file not shown.
49 changes: 26 additions & 23 deletions keyrad.yaml
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
token_url: "https://<your-keycloak-server>/realms/<your-realm>/protocol/openid-connect/token"
client_id: "<your-client-id>"
client_secret: "<your-client-secret>"
realm: "<your-realm>"
api_url: "https://<your-keycloak-server>/admin/realms/<your-realm>" # 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"
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"
Loading