前言

最近有个需求,需要将我们一个平台对接到redmine,让用户可以通过这个平台直接在redmine提工单,需要实现免登录跳转。首先是想到去查redmine有无相应的单点登录功能,查到redmine是有LDAP认证功能的,

解决方案

LDAP认证

Redmine 支持通过 LDAP (轻量级目录访问协议) 实现用户认证,这使得它可以与现有的目录服务(如 Active Directory 或 OpenLDAP)集成,进而实现多系统认证。这种集成让用户能够使用他们的公司或组织凭据登录 Redmine,简化了帐户管理并提高了安全性。下面是如何在 Redmine 中配置 LDAP 认证的详细步骤:

  1. 访问 Redmine 管理界面

首先,您需要有 Redmine 的管理员权限才能配置 LDAP 认证。

  • 登录到 Redmine。
  • 导航到 “管理” > “配置” > “LDAP认证”
  1. 新增 LDAP 认证方式

在 LDAP 认证页面,点击 “新增LDAP认证方式”

  1. 填写 LDAP 服务器信息

在新增页面,您需要填写 LDAP 服务器的详细信息:

  • 名称:为您的 LDAP 配置命名,例如 “公司LDAP”。
  • 主机:LDAP 服务器的地址。例如,ldap.example.com
  • 端口:LDAP 服务器的端口,默认是 389,如果使用 SSL,则可能是 636
  • 帐号密码:如果您的 LDAP 服务器不允许匿名绑定,您需要提供一个帐号和密码来绑定LDAP目录。
  • 基准DN:基础 DN(Distinguished Name),用于搜索用户,例如 ou=people,dc=example,dc=com
  • LDAP 过滤器:(可选)一个过滤器,用于在搜索时筛选用户,例如 (objectClass=person)
  • 在登录时动态创建帐户:如果启用,当通过 LDAP 认证的用户首次登录时,Redmine 会自动创建一个对应的用户帐户。
  • 属性映射:配置如何从 LDAP 属性映射到 Redmine 用户属性,例如用户名、邮件等。
  1. 测试连接

填写完毕后,您可以使用提供的测试功能来验证 Redmine 是否能够成功连接到 LDAP 服务器。通常,您需要输入一个有效的 LDAP 用户名和密码来测试是否能够成功认证。

  1. 保存配置

如果测试成功,保存您的配置。现在,Redmine 已配置为使用 LDAP 认证用户。

  1. LDAP 安全性考虑
  • 使用 SSL/TLS:如果可能,应配置 LDAP 服务器以使用 SSL(LDAPS)或通过 STARTTLS 加密通信,以提高安全性。
  • 最小权限原则:用于绑定 LDAP 的帐号应该具有最低必要的权限,仅足以搜索和读取用户信息。
  1. 多系统认证

通过 LDAP 认证集成,Redmine 可以与其他同样配置为从同一 LDAP 目录服务认证的系统共享用户帐户和登录凭据。这意味着用户可以使用同一套凭据在多个系统(如电子邮件、VPN、Redmine 等)中登录,实现单点登录(SSO)的效果。

存在的问题

原先的系统并没有集成LDAP认证,这套方案明显是行不通的,只能找另外的方案。

获取Redmine Cookie实现登录

在谷歌上搜索一些与redmine有关的多端登录的插件,感觉都不太适合当下的场景。

通过Cookie实现Redmine单点登录

在网上看到有这一篇文,感觉可以按这个思路来

从Java后端获取Redmine的cookie代码

redmine登录需要以下的参数,以post方式发送formdata数据,以下是数据:

除了以上数据,header还需要加入一个必要字段Cookie。

这里第一张图的authenticity_token和Cookie,都是用户第一次访问redmine的登录页面时候获取的,这意味着,要拿到登录后的Cookie需要发送两次请求,第一次请求login页面拿到上面两个字段,第二次请求通过POST请求把参数注入,发送完后获取相应的cookie,完整代码如下

@Slf4j
public class RedmineUtil {

    /********************************
     *  @function  : 根据用户和url获取cookie
     *  @parameter : [userName | 用户名, password | 密码, loginUrl | 登录url]
     *  @return    : java.lang.String
     *  @date      : 2024/2/1 14:21
     ********************************/
    public static String getCookie(String userName, String password, String loginUrl) throws IOException {
        HttpURLConnection firstConnection = (HttpURLConnection) new URL(loginUrl).openConnection();
        String cookie = firstConnection.getHeaderField("Set-Cookie");
        int pos = cookie.indexOf(";");
        cookie = cookie.substring(0, pos);
        // 通过正则在登录页面内解析出登录需要的authenticity_token
        String loginPageSource = readResponse(firstConnection);
        String authenticityToken = extractAuthToken(loginPageSource);
        cookie += ";sso=true"; // 这一行后面重点讲
        log.info("username:{},authenticityToken:{}, cookie:{}", userName, authenticityToken, cookie);
        // 登录参数
        LinkedHashMap<String, String> values = new LinkedHashMap<>();
        values.put("utf8", "");
        values.put("authenticity_token", authenticityToken);
        values.put("back_url", "/");
        values.put("username", userName);
        values.put("password", password);
        values.put("login", "登录");

        String formData = getPostDataString(values);

        byte[] bytes = formData.getBytes(StandardCharsets.UTF_8);

        // 请求头
        Map<String, String> headers = new HashMap<>();
        headers.put("Cookie", cookie);
        // // 发送登录表单
        HttpURLConnection secondConnect = sendPostRequest(loginUrl, headers, bytes);
        cookie = secondConnect.getHeaderField("Set-Cookie");
        pos = cookie.indexOf(";");
        int start = cookie.indexOf("=");
        cookie = cookie.substring(start + 1, pos);
        log.info("username:{}, Login Status Code:{}, cookie:{} " ,userName,secondConnect.getResponseCode(), cookie);
        return cookie;
    }

    /********************************
     *  @function  : 读取response
     *  @parameter : [connection | 连接]
     *  @return    : java.lang.String
     *  @date      : 2024/2/4 16:23
     ********************************/
    private static String readResponse(HttpURLConnection connection) throws IOException {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
            StringBuilder response = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                response.append(line);
            }
            return response.toString();
        }
    }

    /********************************
     *  @function  : 通过正则表达式获取token的值
     *  @parameter : [pageSource | 网页内容]
     *  @return    : java.lang.String
     *  @date      : 2024/2/4 16:23
     ********************************/
    private static String extractAuthToken(String pageSource) {
        // 使用正则表达式提取 authenticity_token
        // 这里的正则表达式可能需要根据实际页面结构进行调整
        String pattern = "<meta\\s+name=\"csrf-token\"\\s+content=\"(.*?)\"\\s*/?>";
        java.util.regex.Pattern regex = java.util.regex.Pattern.compile(pattern, java.util.regex.Pattern.DOTALL);
        java.util.regex.Matcher matcher = regex.matcher(pageSource);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }

    /********************************
     *  @function  :
     *  @parameter : [url | 链接, headers | 请求头, bytes | post的body]
     *  @return    : java.net.HttpURLConnection
     *  @date      : 2024/2/4 16:24
     ********************************/
    private static HttpURLConnection sendPostRequest(String url, Map<String, String> headers, byte[] bytes) throws IOException {
        HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
        connection.setRequestMethod("POST");
        connection.setDoOutput(true);
        if (headers != null) {
            setHeaders(connection, headers);
        }
        try (OutputStream os = connection.getOutputStream()) {
            if (bytes != null) {
                os.write(bytes);
            }
            os.flush();
        }
        return connection;
    }

    /********************************
     *  @function  : 设置请求头到connection里面
     *  @parameter : [connection | 连接, headers | 请求头]
     *  @return    : void
     *  @date      : 2024/2/4 16:25
     ********************************/
    private static void setHeaders(HttpURLConnection connection, Map<String, String> headers) {
        for (Map.Entry<String, String> entry : headers.entrySet()) {
            connection.setRequestProperty(entry.getKey(), entry.getValue());
        }
    }

    /********************************
     *  @function  : 对参数进行编码
     *  @parameter : [postData | 表单参数]
     *  @return    : java.lang.String
     *  @date      : 2024/2/1 14:25
     ********************************/
    private static String getPostDataString(Map<String, String> postData) throws UnsupportedEncodingException {
        StringBuilder result = new StringBuilder();
        for (Map.Entry<String, String> entry : postData.entrySet()) {
            result.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
            result.append("=");
            result.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
            result.append("&");
        }
        String resultString = result.toString();
        return resultString.length() > 0 ? resultString.substring(0, resultString.length() - 1) : resultString;
    }
}

遇到的问题

本以为这样子就可以实现,但是拿到的cookie一直是错的,我反复对比了我跟浏览器登录发的参数的区别,看了老久都没找出问题。于是我去查看redmine的log

redmine是使用docker部署的,通过docker logs 容器id,看到的问题是:

无论是浏览器登录和java发起请求登录,redmine里面都successfully authorize,在登录成功后redmine会发起一个302重定向,在发起302重定向的时候,redmine会读取当前的登录状态,java发起的请求当前的登录用户不能够获取到,于是返回的cookie结果不对,但是浏览器的却可以,我去翻了redmine的源码。

进入/redmine/app/controllers/account_controller.rb,

用户调用login接口后,会进入到这个方法def successful_authentication(user):

  def successful_authentication(user)
    logger.info "Successful authentication for '#{user.login}' from #{request.remote_ip} at #{Time.now.utc}"
    # Valid user
    self.logged_user = user
    # generate a key and set cookie if autologin
  if params[:autologin] && Setting.autologin?
      set_autologin_cookie(user)
  else
    call_hook(:controller_account_success_authentication_after, {:user => user})
    redirect_back_or_default my_page_path
  end
  end

每次登录后都会进行302重定向,进入else语句中的call_hook,但是不知道为啥用java发出的请求,在这里面redirect后丢失了登录状态,而且猜想cookie的设置也这种情况下也是在redirect后才设值的,上面的set_autologin_cookie(user)方法如下:

  def set_autologin_cookie(user)
    token = user.generate_autologin_token
    secure = Redmine::Configuration['autologin_cookie_secure']
    if secure.nil?
      secure = request.ssl?
    end
    cookie_options = {
      :value => token,
      :expires => 1.year.from_now,
      :path => (Redmine::Configuration['autologin_cookie_path'] || RedmineApp::Application.config.relative_url_root || '/'),
      :same_site => :lax,
      :secure => secure,
      :httponly => true
    }
    cookies[autologin_cookie_name] = cookie_options
  end

可以看到这个方法里面进行了cookie的设置,那就在登录的时候让他不进行重定向进到这个方法里好了,修改def successful_authentication(user)方法为:

  def successful_authentication(user)
    logger.info "Successful authentication for '#{user.login}' from #{request.remote_ip} at #{Time.now.utc}"
    # Valid user
    self.logged_user = user
    # generate a key and set cookie if autologin
    logger.info " if cookie '#{cookies[:sso] && cookies[:sso] == "true"}'"
   if cookies[:sso] && cookies[:sso] == "true"  
      set_autologin_cookie(user)
  else
    call_hook(:controller_account_success_authentication_after, {:user => user})
    redirect_back_or_default my_page_path
  end
  end

并且在RedmineUtil方法的getCookie方法里面,增加一行代码:

在cookie里面携带sso=true这个标识,就不会进行重定向,直接返回cookie,经过修改后得到的cookie已经能够使用了。

Cookie写到Response返回给前端

接下来需要把cookie返回给前端,在controller层,将cookie直接set到response里面:

    @GetMapping(value = "/redirect")
    public RedirectView redirectToExternalWithCookie(HttpServletResponse response) throws IOException {
        // 创建 Cookie
        String cookie1 = RedmineUtil.getCookie("admin", "xx", "http:/ip/redmine/login");
        Cookie cookie = new Cookie("_redmine_session", cookie1);
        cookie.setPath("/");
        // 根据需要设置其他 Cookie 属性,如 domain, secure 等
        // 将 Cookie 添加到响应中
        response.addCookie(cookie);
        // 重定向到外部地址
        return new RedirectView("http://ip/redmine/projects");
    }

调试的时候,直接访问这个接口,也可以直接重定向到redmine并跳过登录的步骤。