前言
最近有个需求,需要将我们一个平台对接到redmine,让用户可以通过这个平台直接在redmine提工单,需要实现免登录跳转。首先是想到去查redmine有无相应的单点登录功能,查到redmine是有LDAP认证功能的,
解决方案
LDAP认证
Redmine 支持通过 LDAP (轻量级目录访问协议) 实现用户认证,这使得它可以与现有的目录服务(如 Active Directory 或 OpenLDAP)集成,进而实现多系统认证。这种集成让用户能够使用他们的公司或组织凭据登录 Redmine,简化了帐户管理并提高了安全性。下面是如何在 Redmine 中配置 LDAP 认证的详细步骤:
- 访问 Redmine 管理界面
首先,您需要有 Redmine 的管理员权限才能配置 LDAP 认证。
- 登录到 Redmine。
- 导航到 “管理” > “配置” > “LDAP认证”。
- 新增 LDAP 认证方式
在 LDAP 认证页面,点击 “新增LDAP认证方式”。
- 填写 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 用户属性,例如用户名、邮件等。
- 测试连接
填写完毕后,您可以使用提供的测试功能来验证 Redmine 是否能够成功连接到 LDAP 服务器。通常,您需要输入一个有效的 LDAP 用户名和密码来测试是否能够成功认证。
- 保存配置
如果测试成功,保存您的配置。现在,Redmine 已配置为使用 LDAP 认证用户。
- LDAP 安全性考虑
- 使用 SSL/TLS:如果可能,应配置 LDAP 服务器以使用 SSL(LDAPS)或通过 STARTTLS 加密通信,以提高安全性。
- 最小权限原则:用于绑定 LDAP 的帐号应该具有最低必要的权限,仅足以搜索和读取用户信息。
- 多系统认证
通过 LDAP 认证集成,Redmine 可以与其他同样配置为从同一 LDAP 目录服务认证的系统共享用户帐户和登录凭据。这意味着用户可以使用同一套凭据在多个系统(如电子邮件、VPN、Redmine 等)中登录,实现单点登录(SSO)的效果。
存在的问题
原先的系统并没有集成LDAP认证,这套方案明显是行不通的,只能找另外的方案。
获取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并跳过登录的步骤。