Skip to content

Commit 9e17871

Browse files
committed
fix(auth): address LDAP security and stability issues from code review
- Prevent spring-boot-starter-data-ldap AutoConfiguration side effects by setting spring.ldap.urls="" to avoid connection attempts when disabled - Add conditional LdapAutoConfiguration that only creates LdapTemplate when skillhub.ldap.enabled=true - Fix resource leaks in LdapAuthService (DirContext, NamingEnumeration) - Add safeLogHost() to prevent credential exposure in logs - Add connection timeouts (5s connect, 10s read) to prevent hangs - Add LDAP injection prevention via isValidUsername() validation Signed-off-by: jangrui <admin@jangrui.com>
1 parent ea3bf08 commit 9e17871

4 files changed

Lines changed: 137 additions & 15 deletions

File tree

server/skillhub-app/src/main/resources/application.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ spring:
1515
basename: messages
1616
application:
1717
name: skillhub
18+
ldap:
19+
# Prevent spring-boot-starter-data-ldap AutoConfiguration from attempting
20+
# to initialize LDAP connections when LDAP is not configured
21+
urls: ""
1822
lifecycle:
1923
timeout-per-shutdown-phase: 30s
2024
jpa:
@@ -105,6 +109,8 @@ skillhub:
105109
email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local}
106110
email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub}
107111
ldap:
112+
# LDAP authentication configuration
113+
# Set enabled to true and configure url/base/username/password to enable LDAP authentication
108114
enabled: ${SKILLHUB_LDAP_ENABLED:false}
109115
url: ${SKILLHUB_LDAP_URL:}
110116
base: ${SKILLHUB_LDAP_BASE:}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.iflytek.skillhub.auth.config;
2+
3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.ldap.core.LdapTemplate;
7+
import org.springframework.ldap.core.support.LdapContextSource;
8+
9+
/**
10+
* LDAP auto-configuration that creates LdapTemplate only when LDAP is enabled.
11+
* This avoids the startup side effects of spring-boot-starter-data-ldap's
12+
* auto-configuration when LDAP is disabled.
13+
*/
14+
@Configuration
15+
@ConditionalOnProperty(name = "skillhub.ldap.enabled", havingValue = "true")
16+
public class LdapAutoConfiguration {
17+
18+
/**
19+
* Creates an LdapContextSource configured from skillhub.ldap properties.
20+
*/
21+
@Bean
22+
public LdapContextSource ldapContextSource(LdapProperties ldapProperties) {
23+
LdapContextSource contextSource = new LdapContextSource();
24+
contextSource.setUrl(ldapProperties.getUrl());
25+
contextSource.setBase(ldapProperties.getBase());
26+
if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) {
27+
contextSource.setUserDn(ldapProperties.getUsername());
28+
contextSource.setPassword(ldapProperties.getPassword());
29+
}
30+
return contextSource;
31+
}
32+
33+
/**
34+
* Creates an LdapTemplate for LDAP operations.
35+
*/
36+
@Bean
37+
public LdapTemplate ldapTemplate(LdapContextSource ldapContextSource) {
38+
return new LdapTemplate(ldapContextSource);
39+
}
40+
}

server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/ldap/LdapAuthService.java

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ public PlatformPrincipal login(String username, String password) {
7474
throw new AuthFlowException(HttpStatus.SERVICE_UNAVAILABLE, "error.auth.ldap.disabled");
7575
}
7676

77-
log.debug("LDAP URL: {}, Base: {}, SearchBase: {}, SearchAttr: {}",
78-
ldapProperties.getUrl(),
77+
log.debug("LDAP host: {}, base: {}, searchBase: {}, searchAttr: {}",
78+
safeLogHost(ldapProperties.getUrl()),
7979
ldapProperties.getBase(),
8080
ldapProperties.getUserSearchBase(),
8181
ldapProperties.getUserSearchAttribute());
@@ -138,6 +138,12 @@ private void ensureUserCanLogin(UserAccount user) {
138138
* Finds the DN (Distinguished Name) of a user in LDAP.
139139
*/
140140
private String findUserDn(String username) {
141+
// LDAP injection prevention: validate username before search
142+
if (!isValidUsername(username)) {
143+
log.warn("Invalid username format for LDAP search: {}", username);
144+
return null;
145+
}
146+
141147
DirContext ctx = null;
142148
javax.naming.NamingEnumeration<SearchResult> results = null;
143149
try {
@@ -181,12 +187,16 @@ private DirContext createLdapContext() throws NamingException {
181187
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
182188
env.put(Context.PROVIDER_URL, ldapProperties.getUrl());
183189
env.put(Context.SECURITY_AUTHENTICATION, "simple");
184-
190+
191+
// Connection timeout: 5 seconds for connect, 10 seconds for read
192+
env.put("com.sun.jndi.ldap.connect.timeout", "5000");
193+
env.put("com.sun.jndi.ldap.read.timeout", "10000");
194+
185195
if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) {
186196
env.put(Context.SECURITY_PRINCIPAL, ldapProperties.getUsername());
187197
env.put(Context.SECURITY_CREDENTIALS, ldapProperties.getPassword());
188198
}
189-
199+
190200
return new InitialDirContext(env);
191201
}
192202

@@ -203,48 +213,79 @@ private void closeContext(DirContext ctx) {
203213
}
204214
}
205215

216+
/**
217+
* Safely extracts host from LDAP URL for logging, avoiding credential exposure.
218+
* Handles formats like: ldap://host:389, ldap://user:pass@host:389, ldaps://host
219+
*/
220+
private String safeLogHost(String url) {
221+
if (url == null || url.isEmpty()) {
222+
return "";
223+
}
224+
try {
225+
// Remove protocol prefix
226+
String withoutProtocol = url.replaceFirst("^ldaps?://", "");
227+
// Extract host:port or just host
228+
int atIndex = withoutProtocol.indexOf('@');
229+
if (atIndex > 0) {
230+
withoutProtocol = withoutProtocol.substring(atIndex + 1);
231+
}
232+
int colonIndex = withoutProtocol.indexOf(':');
233+
return colonIndex > 0 ? withoutProtocol.substring(0, colonIndex) : withoutProtocol;
234+
} catch (Exception e) {
235+
return "[url-parse-error]";
236+
}
237+
}
238+
206239
/**
207240
* Authenticates a user against the LDAP server using their DN and password.
208241
*/
209242
private boolean authenticateLdap(String userDn, String password) {
243+
DirContext ctx = null;
210244
try {
211245
Hashtable<String, String> env = new Hashtable<>();
212246
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
213247
env.put(Context.PROVIDER_URL, ldapProperties.getUrl());
214248
env.put(Context.SECURITY_AUTHENTICATION, "simple");
215249
env.put(Context.SECURITY_PRINCIPAL, userDn);
216250
env.put(Context.SECURITY_CREDENTIALS, password);
251+
env.put("com.sun.jndi.ldap.connect.timeout", "5000");
252+
env.put("com.sun.jndi.ldap.read.timeout", "10000");
217253

218-
DirContext ctx = new InitialDirContext(env);
219-
ctx.close();
254+
ctx = new InitialDirContext(env);
220255
return true;
221256
} catch (NamingException e) {
222257
return false;
258+
} finally {
259+
closeContext(ctx);
223260
}
224261
}
225262

226263
/**
227264
* Retrieves user attributes from LDAP.
228265
*/
229266
private Attributes getUserAttributes(String userDn) {
267+
DirContext ctx = null;
230268
try {
231269
Hashtable<String, String> env = new Hashtable<>();
232270
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
233271
env.put(Context.PROVIDER_URL, ldapProperties.getUrl());
234272
env.put(Context.SECURITY_AUTHENTICATION, "simple");
235-
273+
env.put("com.sun.jndi.ldap.connect.timeout", "5000");
274+
env.put("com.sun.jndi.ldap.read.timeout", "10000");
275+
236276
// Use bind DN if configured, otherwise anonymous bind
237277
if (ldapProperties.getUsername() != null && !ldapProperties.getUsername().isEmpty()) {
238278
env.put(Context.SECURITY_PRINCIPAL, ldapProperties.getUsername());
239279
env.put(Context.SECURITY_CREDENTIALS, ldapProperties.getPassword());
240280
}
241-
242-
DirContext ctx = new InitialDirContext(env);
281+
282+
ctx = new InitialDirContext(env);
243283
Attributes attrs = ctx.getAttributes(new LdapName(userDn));
244-
ctx.close();
245284
return attrs;
246285
} catch (Exception e) {
247286
return null;
287+
} finally {
288+
closeContext(ctx);
248289
}
249290
}
250291

@@ -272,8 +313,11 @@ private UserAccount findOrCreateLdapUser(String username, Attributes attributes)
272313

273314
// If not found, create a new user
274315
if (user == null) {
275-
// Use a unique identifier based on username if email is missing
276-
// This prevents creating duplicate accounts for users without email
316+
// For LDAP users without email, use "ldap:{username}@internal" as a unique identifier.
317+
// This format:
318+
// 1. Prevents duplicate accounts when email attribute is missing
319+
// 2. Clearly identifies the account origin (LDAP vs local)
320+
// 3. Follows email format to satisfy the email NOT NULL constraint
277321
String normalizedEmail = email != null ? email.toLowerCase() : "ldap:" + username + "@internal";
278322

279323
user = new UserAccount(
@@ -322,4 +366,16 @@ private PlatformPrincipal buildPrincipal(UserAccount user) {
322366
roles
323367
);
324368
}
369+
370+
/**
371+
* Validates username to prevent LDAP injection attacks.
372+
* Allows only alphanumeric characters and underscores, 3-64 characters.
373+
*/
374+
private boolean isValidUsername(String username) {
375+
if (username == null || username.isEmpty()) {
376+
return false;
377+
}
378+
// Allow alphanumeric, underscore, hyphen, dot, and @ for UPN formats
379+
return username.matches("^[A-Za-z0-9_@.\\-]{3,64}$");
380+
}
325381
}

server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ public PlatformPrincipal login(String username, String password) {
134134
// Fallback to LDAP authentication if enabled
135135
if (ldapProperties.isEnabled()) {
136136
log.info("Local user not found, attempting LDAP authentication for username: {}", username);
137-
log.debug("LDAP enabled: {}, URL: {}, Base: {}",
138-
ldapProperties.isEnabled(),
139-
ldapProperties.getUrl(),
137+
log.debug("LDAP enabled: {}, host: {}, base: {}",
138+
ldapProperties.isEnabled(),
139+
safeLogHost(ldapProperties.getUrl()),
140140
ldapProperties.getBase());
141141
try {
142142
PlatformPrincipal ldapPrincipal = ldapAuthService.login(username, password);
@@ -271,4 +271,24 @@ private void validateEmail(String email) {
271271
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid");
272272
}
273273
}
274+
275+
/**
276+
* Safely extracts host from LDAP URL for logging, avoiding credential exposure.
277+
*/
278+
private static String safeLogHost(String url) {
279+
if (url == null || url.isEmpty()) {
280+
return "";
281+
}
282+
try {
283+
String withoutProtocol = url.replaceFirst("^ldaps?://", "");
284+
int atIndex = withoutProtocol.indexOf('@');
285+
if (atIndex > 0) {
286+
withoutProtocol = withoutProtocol.substring(atIndex + 1);
287+
}
288+
int colonIndex = withoutProtocol.indexOf(':');
289+
return colonIndex > 0 ? withoutProtocol.substring(0, colonIndex) : withoutProtocol;
290+
} catch (Exception e) {
291+
return "[url-parse-error]";
292+
}
293+
}
274294
}

0 commit comments

Comments
 (0)