mirror of
https://github.com/versity/versitygw.git
synced 2025-12-23 05:05:16 +00:00
In the IPA `GetUserAccount` function, the `userID`, `groupID`, and `projectID` values were parsed from a string slice into integers, which caused a panic when the slice was empty. This has been fixed by defaulting empty slices to `0` for each ID.
520 lines
12 KiB
Go
520 lines
12 KiB
Go
// Copyright 2025 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/versity/versitygw/debuglogger"
|
|
)
|
|
|
|
const IpaVersion = "2.254"
|
|
|
|
type IpaIAMService struct {
|
|
client http.Client
|
|
id int
|
|
version string
|
|
host string
|
|
vaultName string
|
|
username string
|
|
password string
|
|
kraTransportKey *rsa.PublicKey
|
|
rootAcc Account
|
|
}
|
|
|
|
var _ IAMService = &IpaIAMService{}
|
|
|
|
func NewIpaIAMService(rootAcc Account, host, vaultName, username, password string, isInsecure bool) (*IpaIAMService, error) {
|
|
ipa := IpaIAMService{
|
|
id: 0,
|
|
version: IpaVersion,
|
|
host: host,
|
|
vaultName: vaultName,
|
|
username: username,
|
|
password: password,
|
|
rootAcc: rootAcc,
|
|
}
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
// this should never happen
|
|
return nil, fmt.Errorf("cookie jar creation: %w", err)
|
|
}
|
|
|
|
mTLSConfig := &tls.Config{InsecureSkipVerify: isInsecure}
|
|
tr := &http.Transport{
|
|
TLSClientConfig: mTLSConfig,
|
|
Proxy: http.ProxyFromEnvironment,
|
|
}
|
|
ipa.client = http.Client{Jar: jar, Transport: tr}
|
|
|
|
err = ipa.login()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ipa login failed: %w", err)
|
|
}
|
|
|
|
req, err := ipa.newRequest("vaultconfig_show/1", []string{}, map[string]any{"all": true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ipa vaultconfig_show: %w", err)
|
|
}
|
|
vaultConfig := struct {
|
|
Kra_Server_Server []string
|
|
Transport_Cert Base64EncodedWrapped
|
|
Wrapping_default_algorithm string
|
|
Wrapping_supported_algorithms []string
|
|
}{}
|
|
err = ipa.rpc(req, &vaultConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ipa vault config: %w", err)
|
|
}
|
|
|
|
cert, err := x509.ParseCertificate(vaultConfig.Transport_Cert)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ipa cannot parse vault certificate: %w", err)
|
|
}
|
|
|
|
ipa.kraTransportKey = cert.PublicKey.(*rsa.PublicKey)
|
|
|
|
isSupported := slices.Contains(vaultConfig.Wrapping_supported_algorithms, "aes-128-cbc")
|
|
|
|
if !isSupported {
|
|
return nil,
|
|
fmt.Errorf("IPA vault does not support aes-128-cbc. Only %v supported",
|
|
vaultConfig.Wrapping_supported_algorithms)
|
|
}
|
|
return &ipa, nil
|
|
}
|
|
|
|
func (ipa *IpaIAMService) CreateAccount(account Account) error {
|
|
return fmt.Errorf("not implemented")
|
|
}
|
|
|
|
func (ipa *IpaIAMService) GetUserAccount(access string) (Account, error) {
|
|
if access == ipa.rootAcc.Access {
|
|
return ipa.rootAcc, nil
|
|
}
|
|
|
|
req, err := ipa.newRequest("user_show/1", []string{access}, map[string]any{})
|
|
if err != nil {
|
|
return Account{}, fmt.Errorf("ipa user_show: %w", err)
|
|
}
|
|
|
|
userResult := struct {
|
|
Gidnumber []string
|
|
Uidnumber []string
|
|
PidNumber []string
|
|
}{}
|
|
|
|
err = ipa.rpc(req, &userResult)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
|
|
uid, err := parseToInt(userResult.Uidnumber, "userID")
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
gid, err := parseToInt(userResult.Gidnumber, "groupID")
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
pId, err := parseToInt(userResult.PidNumber, "projectID")
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
|
|
account := Account{
|
|
Access: access,
|
|
Role: RoleUser,
|
|
UserID: uid,
|
|
GroupID: gid,
|
|
ProjectID: pId,
|
|
}
|
|
|
|
session_key := make([]byte, 16)
|
|
|
|
_, err = rand.Read(session_key)
|
|
if err != nil {
|
|
return account, fmt.Errorf("ipa cannot generate session key: %w", err)
|
|
}
|
|
|
|
encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, ipa.kraTransportKey, session_key)
|
|
if err != nil {
|
|
return account, fmt.Errorf("ipa vault secret retrieval: %w", err)
|
|
}
|
|
|
|
req, err = ipa.newRequest("vault_retrieve_internal/1", []string{ipa.vaultName},
|
|
map[string]any{"username": access,
|
|
"session_key": Base64EncodedWrapped(encryptedKey),
|
|
"wrapping_algo": "aes-128-cbc"})
|
|
if err != nil {
|
|
return Account{}, fmt.Errorf("ipa vault_retrieve_internal: %w", err)
|
|
}
|
|
|
|
data := struct {
|
|
Vault_data Base64EncodedWrapped
|
|
Nonce Base64EncodedWrapped
|
|
}{}
|
|
|
|
err = ipa.rpc(req, &data)
|
|
if err != nil {
|
|
return account, err
|
|
}
|
|
|
|
aes, err := aes.NewCipher(session_key)
|
|
if err != nil {
|
|
return account, fmt.Errorf("ipa cannot create AES cipher: %w", err)
|
|
}
|
|
cbc := cipher.NewCBCDecrypter(aes, data.Nonce)
|
|
cbc.CryptBlocks(data.Vault_data, data.Vault_data)
|
|
secretUnpaddedJson, err := pkcs7Unpad(data.Vault_data, 16)
|
|
if err != nil {
|
|
return account, fmt.Errorf("ipa cannot unpad decrypted result: %w", err)
|
|
}
|
|
|
|
secret := struct {
|
|
Data Base64Encoded
|
|
}{}
|
|
json.Unmarshal(secretUnpaddedJson, &secret)
|
|
account.Secret = string(secret.Data)
|
|
|
|
return account, nil
|
|
}
|
|
|
|
func (ipa *IpaIAMService) UpdateUserAccount(access string, props MutableProps) error {
|
|
return fmt.Errorf("not implemented")
|
|
}
|
|
|
|
func (ipa *IpaIAMService) DeleteUserAccount(access string) error {
|
|
return fmt.Errorf("not implemented")
|
|
}
|
|
|
|
func (ipa *IpaIAMService) ListUserAccounts() ([]Account, error) {
|
|
return []Account{}, fmt.Errorf("not implemented")
|
|
}
|
|
|
|
func (ipa *IpaIAMService) Shutdown() error {
|
|
return nil
|
|
}
|
|
|
|
// Implementation
|
|
|
|
const requestRetries = 3
|
|
|
|
func (ipa *IpaIAMService) login() error {
|
|
form := url.Values{}
|
|
form.Set("user", ipa.username)
|
|
form.Set("password", ipa.password)
|
|
|
|
req, err := http.NewRequest(
|
|
"POST",
|
|
fmt.Sprintf("%s/ipa/session/login_password", ipa.host),
|
|
strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req.Header.Set("referer", fmt.Sprintf("%s/ipa", ipa.host))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
var resp *http.Response
|
|
for i := range requestRetries {
|
|
resp, err = ipa.client.Do(req)
|
|
if err == nil {
|
|
break
|
|
}
|
|
// Check for transient network errors
|
|
if isRetryable(err) {
|
|
time.Sleep(time.Second * time.Duration(i+1))
|
|
continue
|
|
}
|
|
return fmt.Errorf("login POST to %s failed: %w", req.URL, err)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("login POST to %s failed after retries: %w",
|
|
req.URL, err)
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 401 {
|
|
return errors.New("cannot login to FreeIPA: invalid credentials")
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("cannot login to FreeIPA: status code %d",
|
|
resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type rpcRequest = string
|
|
|
|
type rpcResponse struct {
|
|
Result json.RawMessage
|
|
Principal string
|
|
Id int
|
|
Version string
|
|
}
|
|
|
|
func (p rpcResponse) String() string {
|
|
return string(p.Result)
|
|
}
|
|
|
|
var errRpc = errors.New("IPA RPC error")
|
|
|
|
func (ipa *IpaIAMService) rpc(req rpcRequest, value any) error {
|
|
err := ipa.login()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
res, err := ipa.rpcInternal(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return json.Unmarshal(res.Result, value)
|
|
}
|
|
|
|
func (ipa *IpaIAMService) rpcInternal(req rpcRequest) (rpcResponse, error) {
|
|
httpReq, err := http.NewRequest("POST",
|
|
fmt.Sprintf("%s/ipa/session/json", ipa.host),
|
|
strings.NewReader(req))
|
|
if err != nil {
|
|
return rpcResponse{}, err
|
|
}
|
|
|
|
debuglogger.IAMLogf("IPA request: %v", req)
|
|
httpReq.Header.Set("referer", fmt.Sprintf("%s/ipa", ipa.host))
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
var httpResp *http.Response
|
|
for i := range requestRetries {
|
|
httpResp, err = ipa.client.Do(httpReq)
|
|
if err == nil {
|
|
break
|
|
}
|
|
// Check for transient network errors
|
|
if isRetryable(err) {
|
|
time.Sleep(time.Second * time.Duration(i+1))
|
|
continue
|
|
}
|
|
return rpcResponse{}, fmt.Errorf("ipa request to %s failed: %w",
|
|
httpReq.URL, err)
|
|
}
|
|
if err != nil {
|
|
return rpcResponse{},
|
|
fmt.Errorf("ipa request to %s failed after retries: %w",
|
|
httpReq.URL, err)
|
|
}
|
|
|
|
defer httpResp.Body.Close()
|
|
|
|
bytes, err := io.ReadAll(httpResp.Body)
|
|
debuglogger.IAMLogf("IPA response (%v): %v", err, string(bytes))
|
|
if err != nil {
|
|
return rpcResponse{}, err
|
|
}
|
|
|
|
result := struct {
|
|
Result struct {
|
|
Json json.RawMessage `json:"result"`
|
|
Value string `json:"value"`
|
|
Summary any `json:"summary"`
|
|
} `json:"result"`
|
|
Error json.RawMessage `json:"error"`
|
|
Id int `json:"id"`
|
|
Principal string `json:"principal"`
|
|
Version string `json:"version"`
|
|
}{}
|
|
|
|
err = json.Unmarshal(bytes, &result)
|
|
if err != nil {
|
|
return rpcResponse{}, err
|
|
}
|
|
if string(result.Error) != "null" {
|
|
return rpcResponse{}, fmt.Errorf("%s: %w", string(result.Error), errRpc)
|
|
}
|
|
|
|
return rpcResponse{
|
|
Result: result.Result.Json,
|
|
Principal: result.Principal,
|
|
Id: result.Id,
|
|
Version: result.Version,
|
|
}, nil
|
|
}
|
|
|
|
func isRetryable(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
if errors.Is(err, io.EOF) {
|
|
return true
|
|
}
|
|
|
|
if err, ok := err.(net.Error); ok && err.Timeout() {
|
|
return true
|
|
}
|
|
|
|
if opErr, ok := err.(*net.OpError); ok {
|
|
if sysErr, ok := opErr.Err.(*syscall.Errno); ok {
|
|
if *sysErr == syscall.ECONNRESET {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (ipa *IpaIAMService) newRequest(method string, args []string, dict map[string]any) (rpcRequest, error) {
|
|
|
|
id := ipa.id
|
|
ipa.id++
|
|
|
|
dict["version"] = ipa.version
|
|
|
|
jmethod, errMethod := json.Marshal(method)
|
|
jargs, errArgs := json.Marshal(args)
|
|
jdict, errDict := json.Marshal(dict)
|
|
|
|
err := errors.Join(errMethod, errArgs, errDict)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ipa request invalid: %w", err)
|
|
}
|
|
|
|
request := map[string]interface{}{
|
|
"id": id,
|
|
"method": json.RawMessage(jmethod),
|
|
"params": []json.RawMessage{json.RawMessage(jargs), json.RawMessage(jdict)},
|
|
}
|
|
|
|
requestJSON, err := json.Marshal(request)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
return string(requestJSON), nil
|
|
}
|
|
|
|
// pkcs7Unpad validates and unpads data from the given bytes slice.
|
|
// The returned value will be 1 to n bytes smaller depending on the
|
|
// amount of padding, where n is the block size.
|
|
func pkcs7Unpad(b []byte, blocksize int) ([]byte, error) {
|
|
if blocksize <= 0 {
|
|
return nil, errors.New("invalid blocksize")
|
|
}
|
|
if len(b) == 0 {
|
|
return nil, errors.New("invalid PKCS7 data (empty or not padded)")
|
|
}
|
|
if len(b)%blocksize != 0 {
|
|
return nil, errors.New("invalid padding on input")
|
|
}
|
|
c := b[len(b)-1]
|
|
n := int(c)
|
|
if n == 0 || n > len(b) {
|
|
return nil, errors.New("invalid padding on input")
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
if b[len(b)-n+i] != c {
|
|
return nil, errors.New("invalid padding on input")
|
|
}
|
|
}
|
|
return b[:len(b)-n], nil
|
|
}
|
|
|
|
/*
|
|
e.g.
|
|
|
|
"value" {
|
|
"__base64__": "aGVsbG93b3JsZAo="
|
|
}
|
|
*/
|
|
type Base64EncodedWrapped []byte
|
|
|
|
func (b *Base64EncodedWrapped) UnmarshalJSON(data []byte) error {
|
|
intermediate := struct {
|
|
Base64 string `json:"__base64__"`
|
|
}{}
|
|
err := json.Unmarshal(data, &intermediate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*b, err = base64.StdEncoding.DecodeString(intermediate.Base64)
|
|
return err
|
|
}
|
|
|
|
func (b *Base64EncodedWrapped) MarshalJSON() ([]byte, error) {
|
|
intermediate := struct {
|
|
Base64 string `json:"__base64__"`
|
|
}{Base64: base64.StdEncoding.EncodeToString(*b)}
|
|
return json.Marshal(intermediate)
|
|
}
|
|
|
|
/*
|
|
e.g.
|
|
|
|
"value": "aGVsbG93b3JsZAo="
|
|
*/
|
|
type Base64Encoded []byte
|
|
|
|
func (b *Base64Encoded) UnmarshalJSON(data []byte) error {
|
|
var intermediate string
|
|
err := json.Unmarshal(data, &intermediate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*b, err = base64.StdEncoding.DecodeString(intermediate)
|
|
return err
|
|
}
|
|
|
|
// parseToInt parses the first argument of input string slice
|
|
// to an integer. If slice is empty, it defaults to 0
|
|
func parseToInt(input []string, argName string) (int, error) {
|
|
if len(input) == 0 {
|
|
debuglogger.IAMLogf("empty %s slice: defaulting to 0", argName)
|
|
return 0, nil
|
|
}
|
|
|
|
id, err := strconv.Atoi(input[0])
|
|
if err != nil {
|
|
debuglogger.IAMLogf("failed to parse %s: %v", argName, err)
|
|
return 0, fmt.Errorf("invalid %s: %w", argName, err)
|
|
}
|
|
|
|
return id, nil
|
|
}
|