mirror of
https://salsa.debian.org/freeipa-team/freeipa.git
synced 2024-12-24 16:10:02 -06:00
3f6656e09a
SSSD OIDC helper is used for negotiating with OAUTH2 or OIDC end points of external identity providers (IdPs). ipa-otpd daemon now is capable to take either Issuer URL or individual endpoints and call SSSD OIDC helper accordingly. Communication with SSSD OIDC helper can be debugged with the use of a debug variable set in /etc/ipa/default.conf. Man page for default.conf(5) has been updated to provide this information. Fixes: https://pagure.io/freeipa/issue/8805 Signed-off-by: Sumit Bose <sbose@redhat.com> Signed-off-by: Alexander Bokovoy <abokovoy@redhat.com> Reviewed-By: Francisco Trivino <ftrivino@redhat.com> Reviewed-By: Sumit Bose <sbose@redhat.com>
615 lines
18 KiB
C
615 lines
18 KiB
C
/*
|
|
* FreeIPA 2FA companion daemon
|
|
*
|
|
* Authors: Sumit Bose <sbose@redhat.com>
|
|
*
|
|
* Copyright (C) 2021 Red Hat
|
|
* see file 'COPYING' for use and warranty information
|
|
*
|
|
* This program is free software you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
/*
|
|
* This file reaches out to a third-party IdP to handle an OAuth2
|
|
* authentication request (stdio.c/query.c) if the user is configured
|
|
* accordingly. The result is placed in the stdout queue (stdio.c).
|
|
*/
|
|
|
|
#include <krb5/krb5.h>
|
|
#include <stdbool.h>
|
|
#include <unistd.h>
|
|
#include <fcntl.h>
|
|
#include <sys/random.h>
|
|
|
|
#include "internal.h"
|
|
|
|
#define OIDC_CHILD_PATH "/usr/libexec/sssd/oidc_child"
|
|
|
|
struct child_ctx {
|
|
int read_from_child;
|
|
int write_to_child;
|
|
verto_ev *read_ev;
|
|
verto_ev *write_ev;
|
|
verto_ev *child_ev;
|
|
struct otpd_queue_item *item;
|
|
struct otpd_queue_item *saved_item;
|
|
enum oauth2_state oauth2_state;
|
|
};
|
|
|
|
static int set_fd_nonblocking(int fd)
|
|
{
|
|
int flags;
|
|
int ret;
|
|
|
|
flags = fcntl(fd, F_GETFL, 0);
|
|
if (flags == -1) {
|
|
ret = errno;
|
|
return ret;
|
|
}
|
|
|
|
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
|
|
ret = errno;
|
|
return ret;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
static void free_child_ctx(verto_ctx *vctx, verto_ev *ev)
|
|
{
|
|
(void)vctx; /* Unused */
|
|
struct child_ctx *child_ctx;
|
|
|
|
child_ctx = verto_get_private(ev);
|
|
|
|
free(child_ctx);
|
|
}
|
|
|
|
static void oauth2_on_child_exit(verto_ctx *vctx, verto_ev *ev)
|
|
{
|
|
(void)vctx; /* Unused */
|
|
verto_proc_status st;
|
|
|
|
st = verto_get_proc_status(ev);
|
|
|
|
/* The krad req might not be available at this stage anymore, so
|
|
* otpd_log_err() is used. */
|
|
otpd_log_err(0, "Child finished with status [%d].", WEXITSTATUS(st));
|
|
}
|
|
|
|
static void oauth2_on_child_writable(verto_ctx *vctx, verto_ev *ev)
|
|
{
|
|
(void)vctx; /* Unused */
|
|
ssize_t io;
|
|
struct child_ctx *child_ctx;
|
|
|
|
child_ctx = verto_get_private(ev);
|
|
if (child_ctx == NULL) {
|
|
otpd_log_err(EINVAL, "Lost child context");
|
|
verto_del(ev);
|
|
return;
|
|
}
|
|
|
|
if (child_ctx->oauth2_state == OAUTH2_GET_DEVICE_CODE) {
|
|
/* no input needed */
|
|
verto_del(ev);
|
|
return;
|
|
}
|
|
|
|
|
|
io = write(verto_get_fd(ev),
|
|
child_ctx->saved_item->oauth2.device_code_reply,
|
|
strlen(child_ctx->saved_item->oauth2.device_code_reply));
|
|
otpd_queue_item_free(child_ctx->saved_item);
|
|
|
|
if (io < 0) {
|
|
switch (errno) {
|
|
#if defined(EWOULDBLOCK) && (!defined(EAGAIN) || EAGAIN - EWOULDBLOCK != 0)
|
|
case EWOULDBLOCK:
|
|
#endif
|
|
#if defined(EAGAIN)
|
|
case EAGAIN:
|
|
#endif
|
|
case ENOBUFS:
|
|
case EINTR:
|
|
/* In this case, we just need to try again. */
|
|
return;
|
|
default:
|
|
/* Unrecoverable. */
|
|
break;
|
|
}
|
|
otpd_log_err(errno, "Failed to send to child");
|
|
}
|
|
|
|
verto_del(ev);
|
|
}
|
|
|
|
#define min(a,b) ((a) > (b) ? (b) : (a))
|
|
static int add_krad_attr_to_set(struct child_ctx *child_ctx,
|
|
krad_attrset *attrset,
|
|
krb5_data *datap,
|
|
krad_attr attr, const char *message)
|
|
{
|
|
krb5_data state = {0};
|
|
char *p = datap->data;
|
|
unsigned int len = datap->length;
|
|
int ret = 0;
|
|
|
|
do {
|
|
state.data = p;
|
|
state.length = min(MAX_ATTRSIZE - 5, len);
|
|
p += state.length;
|
|
|
|
ret = krad_attrset_add(attrset, attr, &(state));
|
|
if (ret != 0) {
|
|
otpd_log_req(child_ctx->item->req, message);
|
|
break;
|
|
}
|
|
len -= state.length;
|
|
} while (len > 0);
|
|
|
|
return ret;
|
|
}
|
|
|
|
/* Most attributes have limited length (MAX_ATTRSIZE). In order to accept longer
|
|
* values, we will concatenate all the attribute values to single krb5_data. */
|
|
static int get_krad_attr_from_packet(const krad_packet *rres,
|
|
krad_attr attr, krb5_data *_data)
|
|
{
|
|
const krb5_data *rmsg;
|
|
krb5_data data = {0};
|
|
unsigned int memindex;
|
|
unsigned int i;
|
|
|
|
i = 0;
|
|
do {
|
|
rmsg = krad_packet_get_attr(rres, attr, i);
|
|
if (rmsg != NULL) {
|
|
data.length += rmsg->length;
|
|
}
|
|
i++;
|
|
} while (rmsg != NULL);
|
|
|
|
if (data.length == 0) {
|
|
return ENOENT;
|
|
}
|
|
|
|
data.data = malloc(data.length);
|
|
if (data.data == NULL) {
|
|
return ENOMEM;
|
|
}
|
|
|
|
i = 0;
|
|
memindex = 0;
|
|
do {
|
|
rmsg = krad_packet_get_attr(rres, attr, i);
|
|
if (rmsg != NULL) {
|
|
memcpy(&data.data[memindex], rmsg->data, rmsg->length);
|
|
memindex += rmsg->length;
|
|
}
|
|
i++;
|
|
} while (rmsg != NULL);
|
|
|
|
if (memindex != data.length) {
|
|
free(data.data);
|
|
return ERANGE;
|
|
}
|
|
|
|
*_data = data;
|
|
|
|
return 0;
|
|
}
|
|
|
|
/* oidc_child will return two lines.
|
|
* The first is a JSON formatted string containing the device code and other
|
|
* data needed to get the access token in the second round. This will be
|
|
* returned to the caller as Radius Proxy-State so that the caller will send
|
|
* it back in the next round.
|
|
* The second line is the string expected by the krb5 oauth2 pre-auth plugin
|
|
* and will be send to the caller as Radius Reply-Message.
|
|
*/
|
|
static int handle_device_code_reply(struct child_ctx *child_ctx,
|
|
const char *dc_reply, char *rad_reply)
|
|
{
|
|
krad_attrset *attrset = NULL;
|
|
int ret;
|
|
krb5_data data = { 0 };
|
|
struct otpd_queue_item *state_item;
|
|
|
|
ret = otpd_queue_item_new(NULL, &state_item);
|
|
if (ret != 0) {
|
|
otpd_log_req(child_ctx->item->req, "Failed to allocate state item");
|
|
goto done;
|
|
}
|
|
|
|
state_item->oauth2.device_code_reply = strdup(dc_reply);
|
|
if (state_item->oauth2.device_code_reply == NULL) {
|
|
otpd_log_req(child_ctx->item->req, "Failed to copy device code reply.");
|
|
goto done;
|
|
}
|
|
|
|
ret = krad_attrset_new(ctx.kctx, &attrset);
|
|
if (ret != 0) {
|
|
otpd_log_req(child_ctx->item->req,
|
|
"Failed to create radius attribute set");
|
|
goto done;
|
|
}
|
|
|
|
state_item->oauth2.state.magic = 0;
|
|
|
|
state_item->oauth2.state.data = strdup(dc_reply);
|
|
if (state_item->oauth2.state.data == NULL) {
|
|
otpd_log_req(child_ctx->item->req,
|
|
"Failed to copy device code reply to krad.");
|
|
goto done;
|
|
}
|
|
state_item->oauth2.state.length = strlen(dc_reply);
|
|
|
|
ret = add_krad_attr_to_set(child_ctx, attrset, &(state_item->oauth2.state),
|
|
krad_attr_name2num("Proxy-State"),
|
|
"Failed to serialize state to attribute set");
|
|
if (ret != 0) {
|
|
goto done;
|
|
}
|
|
|
|
data.magic = 0;
|
|
data.data = rad_reply;
|
|
data.length = strlen(rad_reply);
|
|
ret = add_krad_attr_to_set(child_ctx, attrset, &data,
|
|
krad_attr_name2num("Reply-Message"),
|
|
"Failed to serialize reply to attribute set");
|
|
if (ret != 0) {
|
|
goto done;
|
|
}
|
|
|
|
ret = krad_packet_new_response(ctx.kctx, SECRET,
|
|
krad_code_name2num("Access-Challenge"),
|
|
attrset,
|
|
child_ctx->item->req, &child_ctx->item->rsp);
|
|
if (ret != 0) {
|
|
otpd_log_err(ret, "Failed to create radius response");
|
|
child_ctx->item->rsp = NULL;
|
|
}
|
|
|
|
otpd_queue_push(&ctx.oauth2_state.states, state_item);
|
|
|
|
ret = 0;
|
|
done:
|
|
krad_attrset_free(attrset);
|
|
if (ret != 0) {
|
|
if (state_item != NULL) {
|
|
free(state_item->oauth2.state.data);
|
|
free(state_item->oauth2.device_code_reply);
|
|
free(state_item);
|
|
}
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
static int check_access_token_reply(struct child_ctx *child_ctx,
|
|
const char *buf, size_t len)
|
|
{
|
|
int ret;
|
|
|
|
if (strlen(child_ctx->item->user.ipaidpSub) != len
|
|
|| memcmp(child_ctx->item->user.ipaidpSub, buf, len) != 0) {
|
|
return EPERM;
|
|
}
|
|
|
|
ret = krad_packet_new_response(ctx.kctx, SECRET,
|
|
krad_code_name2num("Access-Accept"), NULL,
|
|
child_ctx->item->req, &child_ctx->item->rsp);
|
|
if (ret != 0) {
|
|
otpd_log_err(ret, "Failed to create radius response");
|
|
child_ctx->item->rsp = NULL;
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
static void oauth2_on_child_readable(verto_ctx *, verto_ev *ev)
|
|
{
|
|
static char buf[10240];
|
|
ssize_t io = 0;
|
|
struct child_ctx *child_ctx = NULL;
|
|
int ret;
|
|
char *rad_reply;
|
|
char *end;
|
|
|
|
child_ctx = (struct child_ctx *) verto_get_private(ev);
|
|
if (child_ctx == NULL) {
|
|
otpd_log_err(EINVAL, "Lost child context");
|
|
verto_del(ev);
|
|
return;
|
|
}
|
|
/* Make sure ctx.stdio.responses will at least return an error */
|
|
child_ctx->item->rsp = NULL;
|
|
child_ctx->item->sent = 0;
|
|
|
|
io = read(verto_get_fd(ev), buf, 10240);
|
|
if (io < 0) {
|
|
otpd_log_err(errno, "Failed to read from child");
|
|
goto done;
|
|
}
|
|
|
|
if (io >= 0) {
|
|
buf[io] = '\0';
|
|
otpd_log_req(child_ctx->item->req, "Received: [%s]", buf);
|
|
}
|
|
|
|
verto_del(ev);
|
|
|
|
if (child_ctx->oauth2_state == OAUTH2_GET_DEVICE_CODE) {
|
|
/* expect 2 lines of output. First the orginal JSON string return by
|
|
* the IdP from the devicecode request which will be used as input to
|
|
* the child process in the second run. Second the JSON string returned
|
|
* in the radius reply. */
|
|
|
|
rad_reply = memchr(buf, '\n', io);
|
|
if (rad_reply != NULL) {
|
|
*rad_reply = '\0';
|
|
rad_reply++;
|
|
end = memchr(rad_reply, '\n', io - (rad_reply - 1 - buf));
|
|
if (end == NULL) {
|
|
otpd_log_req(child_ctx->item->req, "Missing second new-line.");
|
|
goto done;
|
|
}
|
|
*end = '\0';
|
|
|
|
ret = handle_device_code_reply(child_ctx, buf, rad_reply);
|
|
if (ret != 0) {
|
|
otpd_log_req(child_ctx->item->req,
|
|
"Failed to handle device code reply.");
|
|
}
|
|
}
|
|
} else if (child_ctx->oauth2_state == OAUTH2_GET_ACCESS_TOKEN) {
|
|
ret = check_access_token_reply(child_ctx, buf, (size_t) io);
|
|
if (ret != 0) {
|
|
otpd_log_req(child_ctx->item->req,
|
|
"Failed to check access token reply.");
|
|
}
|
|
} else {
|
|
/* error */
|
|
otpd_log_req(child_ctx->item->req, "Unexpected state [%d].",
|
|
child_ctx->oauth2_state);
|
|
}
|
|
|
|
done:
|
|
otpd_queue_push(&ctx.stdio.responses, child_ctx->item);
|
|
verto_set_flags(ctx.stdio.writer, VERTO_EV_FLAG_PERSIST |
|
|
VERTO_EV_FLAG_IO_ERROR |
|
|
VERTO_EV_FLAG_IO_READ |
|
|
VERTO_EV_FLAG_IO_WRITE);
|
|
}
|
|
|
|
static const char *oauth2_state_to_str(enum oauth2_state oauth2_state)
|
|
{
|
|
switch (oauth2_state) {
|
|
case OAUTH2_NO:
|
|
return "OAuth2 not available";
|
|
break;
|
|
case OAUTH2_GET_ISSUER:
|
|
return "Get issuer from LDAP";
|
|
break;
|
|
case OAUTH2_GET_DEVICE_CODE:
|
|
return "Get device code";
|
|
break;
|
|
case OAUTH2_GET_ACCESS_TOKEN:
|
|
return "Get access token";
|
|
break;
|
|
default:
|
|
return "Unknown OAuth2 state";
|
|
}
|
|
}
|
|
|
|
int oauth2(struct otpd_queue_item **item, enum oauth2_state oauth2_state)
|
|
{
|
|
int ret;
|
|
pid_t child_pid;
|
|
int pipefd_to_child[2] = { -1, -1};
|
|
int pipefd_from_child[2] = { -1, -1};
|
|
struct child_ctx *child_ctx;
|
|
/* Up to 50 arguments to the helper supported. The amount of arguments
|
|
* is controlled inside this function. Right now max used is below 20 */
|
|
char *args[50] = {NULL};
|
|
size_t args_idx = 0;
|
|
krb5_data data_state = {0};
|
|
struct otpd_queue_item *saved_item;
|
|
|
|
if (oauth2_state != OAUTH2_GET_DEVICE_CODE
|
|
&& oauth2_state != OAUTH2_GET_ACCESS_TOKEN) {
|
|
otpd_log_req((*item)->req, "Unexpected OAuth2 state [%d][%s]",
|
|
oauth2_state, oauth2_state_to_str(oauth2_state));
|
|
return EINVAL;
|
|
}
|
|
|
|
if (oauth2_state == OAUTH2_GET_ACCESS_TOKEN) {
|
|
ret = get_krad_attr_from_packet((*item)->req,
|
|
krad_attr_name2num("Proxy-State"),
|
|
&data_state);
|
|
if ((ret != 0) || (data_state.length == 0)) {
|
|
otpd_log_req((*item)->req, "Missing Radius Proxy-State attribute");
|
|
return EINVAL;
|
|
}
|
|
|
|
saved_item = calloc(sizeof(struct otpd_queue_item), 1);
|
|
if (saved_item == NULL) {
|
|
otpd_log_req((*item)->req, "No matching saved state found");
|
|
return EINVAL;
|
|
}
|
|
saved_item->oauth2.device_code_reply = strndup(data_state.data,
|
|
data_state.length);
|
|
if (saved_item->oauth2.device_code_reply == NULL) {
|
|
otpd_log_req((*item)->req, "Failed to copy device code reply");
|
|
return EINVAL;
|
|
}
|
|
krb5_free_data_contents(NULL, &data_state);
|
|
}
|
|
|
|
child_ctx = calloc(sizeof(struct child_ctx), 1);
|
|
if (child_ctx == NULL) {
|
|
ret = ENOMEM;
|
|
goto done;
|
|
}
|
|
child_ctx->item = (*item);
|
|
child_ctx->saved_item = saved_item;
|
|
child_ctx->oauth2_state = oauth2_state;
|
|
|
|
otpd_log_req((*item)->req, "oauth2 start: %s",
|
|
oauth2_state_to_str(oauth2_state));
|
|
|
|
args[args_idx++] = OIDC_CHILD_PATH;
|
|
|
|
if (oauth2_state == OAUTH2_GET_DEVICE_CODE) {
|
|
args[args_idx++] = "--get-device-code";
|
|
} else {
|
|
args[args_idx++] = "--get-access-token";
|
|
}
|
|
|
|
if ((*item)->idp.ipaidpIssuerURL != NULL) {
|
|
args[args_idx++] = "--issuer-url";
|
|
args[args_idx++] = (*item)->idp.ipaidpIssuerURL;
|
|
} else {
|
|
args[args_idx++] = "--device-auth-endpoint";
|
|
args[args_idx++] = (*item)->idp.ipaidpDevAuthEndpoint;
|
|
|
|
args[args_idx++] = "--token-endpoint";
|
|
args[args_idx++] = (*item)->idp.ipaidpTokenEndpoint;
|
|
|
|
args[args_idx++] = "--userinfo-endpoint";
|
|
args[args_idx++] = (*item)->idp.ipaidpUserInfoEndpoint;
|
|
|
|
if ((*item)->idp.ipaidpKeysEndpoint) {
|
|
args[args_idx++] = "--jwks-uri";
|
|
args[args_idx++] = (*item)->idp.ipaidpKeysEndpoint;
|
|
}
|
|
|
|
}
|
|
|
|
args[args_idx++] = "--client-id";
|
|
args[args_idx++] = (*item)->idp.ipaidpClientID;
|
|
|
|
if ((*item)->idp.ipaidpClientSecret) {
|
|
args[args_idx++] = "--client-secret";
|
|
args[args_idx++] = (*item)->idp.ipaidpClientSecret;
|
|
}
|
|
|
|
if ((*item)->idp.ipaidpScope) {
|
|
args[args_idx++] = "--scope";
|
|
args[args_idx++] = (*item)->idp.ipaidpScope;
|
|
}
|
|
|
|
if ((*item)->idp.ipaidpSub) {
|
|
args[args_idx++] = "--user-identifier-attribute";
|
|
args[args_idx++] = (*item)->idp.ipaidpSub;
|
|
}
|
|
|
|
if ((*item)->idp.ipaidpDebugLevelStr != NULL) {
|
|
args[args_idx++] = "--debug-level";
|
|
args[args_idx++] = (*item)->idp.ipaidpDebugLevelStr;
|
|
}
|
|
|
|
if ((*item)->idp.ipaidpDebugCurl) {
|
|
args[args_idx++] = "--libcurl-debug";
|
|
}
|
|
|
|
#if 0
|
|
for (int i; args[i]; i++) {
|
|
otpd_log_req((*item)->req, "oidc_child exec: %s", args[i]);
|
|
}
|
|
#endif
|
|
|
|
ret = pipe(pipefd_from_child);
|
|
if (ret == -1) {
|
|
ret = errno;
|
|
goto done;
|
|
}
|
|
ret = pipe(pipefd_to_child);
|
|
if (ret == -1) {
|
|
ret = errno;
|
|
goto done;
|
|
}
|
|
|
|
child_pid = fork();
|
|
|
|
if (child_pid == 0) { /* child */
|
|
close(pipefd_to_child[1]);
|
|
ret = dup2(pipefd_to_child[0], STDIN_FILENO);
|
|
if (ret == -1) {
|
|
exit(EXIT_FAILURE);
|
|
}
|
|
|
|
close(pipefd_from_child[0]);
|
|
ret = dup2(pipefd_from_child[1], STDOUT_FILENO);
|
|
if (ret == -1) {
|
|
exit(EXIT_FAILURE);
|
|
}
|
|
|
|
execv(OIDC_CHILD_PATH, args);
|
|
exit(EXIT_FAILURE);
|
|
} else if (child_pid > 0) { /* parent */
|
|
close(pipefd_to_child[0]);
|
|
set_fd_nonblocking(pipefd_to_child[1]);
|
|
child_ctx->write_to_child = pipefd_to_child[1];
|
|
|
|
close(pipefd_from_child[1]);
|
|
set_fd_nonblocking(pipefd_from_child[0]);
|
|
child_ctx->read_from_child = pipefd_from_child[0];
|
|
|
|
child_ctx->write_ev = verto_add_io(ctx.vctx, VERTO_EV_FLAG_PERSIST |
|
|
VERTO_EV_FLAG_IO_CLOSE_FD |
|
|
VERTO_EV_FLAG_IO_ERROR |
|
|
VERTO_EV_FLAG_IO_WRITE,
|
|
oauth2_on_child_writable,
|
|
child_ctx->write_to_child);
|
|
if (child_ctx->write_ev == NULL) {
|
|
ret = ENOMEM;
|
|
otpd_log_err(ret, "Unable to initialize oauth2 writer event");
|
|
goto done;
|
|
}
|
|
verto_set_private(child_ctx->write_ev, child_ctx, NULL);
|
|
|
|
child_ctx->read_ev = verto_add_io(ctx.vctx, VERTO_EV_FLAG_PERSIST |
|
|
VERTO_EV_FLAG_IO_CLOSE_FD |
|
|
VERTO_EV_FLAG_IO_ERROR |
|
|
VERTO_EV_FLAG_IO_READ,
|
|
oauth2_on_child_readable,
|
|
child_ctx->read_from_child);
|
|
if (child_ctx->read_ev == NULL) {
|
|
ret = ENOMEM;
|
|
otpd_log_err(ret, "Unable to initialize oauth2 writer event");
|
|
goto done;
|
|
}
|
|
verto_set_private(child_ctx->read_ev, child_ctx, NULL);
|
|
|
|
child_ctx->child_ev = verto_add_child(ctx.vctx, VERTO_EV_FLAG_NONE,
|
|
oauth2_on_child_exit, child_pid);
|
|
verto_set_private(child_ctx->child_ev, child_ctx, free_child_ctx);
|
|
|
|
} else { /* error */
|
|
ret = errno;
|
|
otpd_log_err(ret, "Failed to fork oidc_child");
|
|
goto done;
|
|
}
|
|
|
|
ret = 0;
|
|
done:
|
|
if (ret == 0) {
|
|
*item = NULL;
|
|
}
|
|
|
|
return ret;
|
|
}
|