Skip to content

Commit c6418a2

Browse files
goekayfaculoyarte
authored andcommitted
switch to basic auth for API access (steve-community#1545)
* switch to basic auth for API access * PR feedback * add cache for API users * PR feedback * start setting/updating api_password * refactor: undo moveApiTokenFromConfigToDatabase prep
1 parent 3a51284 commit c6418a2

File tree

5 files changed

+168
-95
lines changed

5 files changed

+168
-95
lines changed
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* SteVe - SteckdosenVerwaltung - https://github.com/steve-community/steve
3+
* Copyright (C) 2013-2024 SteVe Community Team
4+
* All Rights Reserved.
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
package de.rwth.idsg.steve.config;
20+
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.google.common.base.Strings;
23+
import de.rwth.idsg.steve.service.WebUserService;
24+
import de.rwth.idsg.steve.web.api.ApiControllerAdvice;
25+
import lombok.RequiredArgsConstructor;
26+
import lombok.extern.slf4j.Slf4j;
27+
import org.springframework.http.HttpStatus;
28+
import org.springframework.http.MediaType;
29+
import org.springframework.security.authentication.AuthenticationManager;
30+
import org.springframework.security.authentication.BadCredentialsException;
31+
import org.springframework.security.authentication.DisabledException;
32+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
33+
import org.springframework.security.core.Authentication;
34+
import org.springframework.security.core.AuthenticationException;
35+
import org.springframework.security.core.userdetails.UserDetails;
36+
import org.springframework.security.crypto.password.PasswordEncoder;
37+
import org.springframework.security.web.AuthenticationEntryPoint;
38+
import org.springframework.stereotype.Component;
39+
40+
import jakarta.servlet.ServletException;
41+
import jakarta.servlet.http.HttpServletRequest;
42+
import jakarta.servlet.http.HttpServletResponse;
43+
44+
import java.io.IOException;
45+
46+
/**
47+
* @author Sevket Goekay <[email protected]>
48+
* @since 17.08.2024
49+
*/
50+
@Slf4j
51+
@Component
52+
@RequiredArgsConstructor
53+
public class ApiAuthenticationManager implements AuthenticationManager, AuthenticationEntryPoint {
54+
55+
private final WebUserService webUserService;
56+
private final PasswordEncoder passwordEncoder;
57+
private final ObjectMapper jacksonObjectMapper;
58+
59+
@Override
60+
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
61+
String username = (String) authentication.getPrincipal();
62+
String apiPassword = (String) authentication.getCredentials();
63+
64+
if (Strings.isNullOrEmpty(username) || Strings.isNullOrEmpty(apiPassword)) {
65+
throw new BadCredentialsException("Required parameters missing");
66+
}
67+
68+
UserDetails userDetails = webUserService.loadUserByUsernameForApi(username);
69+
if (!areValuesSet(userDetails)) {
70+
throw new DisabledException("The user does not exist, exists but is disabled or has API access disabled.");
71+
}
72+
73+
boolean match = passwordEncoder.matches(apiPassword, userDetails.getPassword());
74+
if (!match) {
75+
throw new BadCredentialsException("Invalid password");
76+
}
77+
78+
return UsernamePasswordAuthenticationToken.authenticated(
79+
authentication.getPrincipal(),
80+
authentication.getCredentials(),
81+
userDetails.getAuthorities()
82+
);
83+
}
84+
85+
@Override
86+
public void commence(HttpServletRequest request,
87+
HttpServletResponse response,
88+
AuthenticationException authException) throws IOException, ServletException {
89+
HttpStatus status = HttpStatus.UNAUTHORIZED;
90+
91+
var apiResponse = ApiControllerAdvice.createResponse(
92+
request.getRequestURL().toString(),
93+
status,
94+
authException.getMessage()
95+
);
96+
97+
response.setStatus(status.value());
98+
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
99+
response.getWriter().print(jacksonObjectMapper.writeValueAsString(apiResponse));
100+
}
101+
102+
private static boolean areValuesSet(UserDetails userDetails) {
103+
if (userDetails == null) {
104+
return false;
105+
}
106+
if (!userDetails.isEnabled()) {
107+
return false;
108+
}
109+
if (Strings.isNullOrEmpty(userDetails.getPassword())) {
110+
return false;
111+
}
112+
return true;
113+
}
114+
115+
}

src/main/java/de/rwth/idsg/steve/config/SecurityConfiguration.java

Lines changed: 3 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -18,35 +18,19 @@
1818
*/
1919
package de.rwth.idsg.steve.config;
2020

21-
import com.fasterxml.jackson.databind.ObjectMapper;
22-
import com.google.common.base.Strings;
23-
import de.rwth.idsg.steve.web.api.ApiControllerAdvice;
2421
import lombok.RequiredArgsConstructor;
2522
import lombok.extern.slf4j.Slf4j;
2623
import org.springframework.context.annotation.Bean;
2724
import org.springframework.context.annotation.Configuration;
2825
import org.springframework.core.annotation.Order;
29-
import org.springframework.http.HttpStatus;
30-
import org.springframework.http.MediaType;
31-
import org.springframework.security.authentication.AuthenticationManager;
32-
import org.springframework.security.authentication.DisabledException;
3326
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
3427
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
3528
import org.springframework.security.config.http.SessionCreationPolicy;
36-
import org.springframework.security.core.Authentication;
37-
import org.springframework.security.core.AuthenticationException;
3829
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
3930
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
4031
import org.springframework.security.crypto.password.PasswordEncoder;
41-
import org.springframework.security.web.AuthenticationEntryPoint;
4232
import org.springframework.security.web.SecurityFilterChain;
43-
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
44-
45-
import jakarta.servlet.ServletException;
46-
import jakarta.servlet.http.HttpServletRequest;
47-
import jakarta.servlet.http.HttpServletResponse;
48-
49-
import java.io.IOException;
33+
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
5034

5135
import static de.rwth.idsg.steve.SteveConfiguration.CONFIG;
5236

@@ -105,88 +89,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
10589

10690
@Bean
10791
@Order(1)
108-
public SecurityFilterChain apiKeyFilterChain(HttpSecurity http, ObjectMapper jacksonObjectMapper) throws Exception {
92+
public SecurityFilterChain apiKeyFilterChain(HttpSecurity http, ApiAuthenticationManager apiAuthenticationManager) throws Exception {
10993
return http.securityMatcher(CONFIG.getApiMapping() + "/**")
11094
.csrf(k -> k.disable())
11195
.sessionManagement(k -> k.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
112-
.addFilter(new ApiKeyFilter())
96+
.addFilter(new BasicAuthenticationFilter(apiAuthenticationManager, apiAuthenticationManager))
11397
.authorizeHttpRequests(k -> k.anyRequest().authenticated())
114-
.exceptionHandling(k -> k.authenticationEntryPoint(new ApiKeyAuthenticationEntryPoint(jacksonObjectMapper)))
11598
.build();
11699
}
117-
118-
/**
119-
* Enable Web APIs only if both properties for API key are set. This has two consequences:
120-
* 1) Backwards compatibility: Existing installations with older properties file, that does not include these two
121-
* new keys, will not expose the APIs. Every call will be blocked by default.
122-
* 2) If you want to expose your APIs, you MUST set these properties. This action activates authentication (i.e.
123-
* APIs without authentication are not possible, and this is a good thing).
124-
*/
125-
public static class ApiKeyFilter extends AbstractPreAuthenticatedProcessingFilter implements AuthenticationManager {
126-
127-
private final String headerKey;
128-
private final String headerValue;
129-
private final boolean isApiEnabled;
130-
131-
public ApiKeyFilter() {
132-
setAuthenticationManager(this);
133-
134-
headerKey = CONFIG.getWebApi().getHeaderKey();
135-
headerValue = CONFIG.getWebApi().getHeaderValue();
136-
isApiEnabled = !Strings.isNullOrEmpty(headerKey) && !Strings.isNullOrEmpty(headerValue);
137-
138-
if (!isApiEnabled) {
139-
log.warn("Web APIs will not be exposed. Reason: 'webapi.key' and 'webapi.value' are not set in config file");
140-
}
141-
}
142-
143-
@Override
144-
protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
145-
if (!isApiEnabled) {
146-
throw new DisabledException("Web APIs are not exposed");
147-
}
148-
return request.getHeader(headerKey);
149-
}
150-
151-
@Override
152-
protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
153-
return null;
154-
}
155-
156-
@Override
157-
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
158-
if (!isApiEnabled) {
159-
throw new DisabledException("Web APIs are not exposed");
160-
}
161-
162-
String principal = (String) authentication.getPrincipal();
163-
authentication.setAuthenticated(headerValue.equals(principal));
164-
return authentication;
165-
}
166-
}
167-
168-
public static class ApiKeyAuthenticationEntryPoint implements AuthenticationEntryPoint {
169-
170-
private final ObjectMapper mapper;
171-
172-
private ApiKeyAuthenticationEntryPoint(ObjectMapper mapper) {
173-
this.mapper = mapper;
174-
}
175-
176-
@Override
177-
public void commence(HttpServletRequest request, HttpServletResponse response,
178-
AuthenticationException authException) throws IOException, ServletException {
179-
HttpStatus status = HttpStatus.UNAUTHORIZED;
180-
181-
var apiResponse = ApiControllerAdvice.createResponse(
182-
request.getRequestURL().toString(),
183-
status,
184-
"Full authentication is required to access this resource"
185-
);
186-
187-
response.setStatus(status.value());
188-
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
189-
response.getWriter().print(mapper.writeValueAsString(apiResponse));
190-
}
191-
}
192100
}

src/main/java/de/rwth/idsg/steve/repository/impl/WebUserRepositoryImpl.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public void createUser(WebUserRecord user) {
4646
ctx.insertInto(WEB_USER)
4747
.set(WEB_USER.USERNAME, user.getUsername())
4848
.set(WEB_USER.PASSWORD, user.getPassword())
49+
.set(WEB_USER.API_PASSWORD, user.getApiPassword())
4950
.set(WEB_USER.ENABLED, user.getEnabled())
5051
.set(WEB_USER.AUTHORITIES, user.getAuthorities())
5152
.execute();
@@ -55,6 +56,7 @@ public void createUser(WebUserRecord user) {
5556
public void updateUser(WebUserRecord user) {
5657
ctx.update(WEB_USER)
5758
.set(WEB_USER.PASSWORD, user.getPassword())
59+
.set(WEB_USER.API_PASSWORD, user.getApiPassword())
5860
.set(WEB_USER.ENABLED, user.getEnabled())
5961
.set(WEB_USER.AUTHORITIES, user.getAuthorities())
6062
.where(WEB_USER.USERNAME.eq(user.getUsername()))

src/main/java/de/rwth/idsg/steve/service/WebUserService.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
import com.fasterxml.jackson.core.JsonProcessingException;
2222
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import com.google.common.cache.Cache;
24+
import com.google.common.cache.CacheBuilder;
2325
import de.rwth.idsg.steve.SteveConfiguration;
2426
import de.rwth.idsg.steve.repository.WebUserRepository;
2527
import jooq.steve.db.tables.records.WebUserRecord;
@@ -41,7 +43,10 @@
4143
import org.springframework.util.Assert;
4244

4345
import java.util.Collection;
46+
import java.util.Collections;
4447
import java.util.LinkedHashSet;
48+
import java.util.concurrent.ExecutionException;
49+
import java.util.concurrent.TimeUnit;
4550
import java.util.stream.Collectors;
4651

4752
import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated;
@@ -57,10 +62,18 @@
5762
@RequiredArgsConstructor
5863
public class WebUserService implements UserDetailsManager {
5964

65+
// Because Guava's cache does not accept a null value
66+
private static final UserDetails DUMMY_USER = new User("#", "#", Collections.emptyList());
67+
6068
private final ObjectMapper jacksonObjectMapper;
6169
private final WebUserRepository webUserRepository;
6270
private final SecurityContextHolderStrategy securityContextHolderStrategy = getContextHolderStrategy();
6371

72+
private final Cache<String, UserDetails> userCache = CacheBuilder.newBuilder()
73+
.expireAfterWrite(10, TimeUnit.MINUTES) // TTL
74+
.maximumSize(100)
75+
.build();
76+
6477
@EventListener
6578
public void afterStart(ContextRefreshedEvent event) {
6679
if (this.hasUserWithAuthority("ADMIN")) {
@@ -140,6 +153,20 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx
140153
.build();
141154
}
142155

156+
public UserDetails loadUserByUsernameForApi(String username) {
157+
try {
158+
UserDetails userExt = userCache.get(username, () -> {
159+
UserDetails user = this.loadUserByUsernameForApiInternal(username);
160+
// map null to dummy
161+
return (user == null) ? DUMMY_USER : user;
162+
});
163+
// map dummy back to null
164+
return (userExt == DUMMY_USER) ? null : userExt;
165+
} catch (ExecutionException e) {
166+
throw new RuntimeException(e);
167+
}
168+
}
169+
143170
public void deleteUser(int webUserPk) {
144171
webUserRepository.deleteUser(webUserPk);
145172
}
@@ -153,6 +180,26 @@ public boolean hasUserWithAuthority(String authority) {
153180
return count != null && count > 0;
154181
}
155182

183+
private UserDetails loadUserByUsernameForApiInternal(String username) {
184+
WebUserRecord record = webUserRepository.loadUserByUsername(username);
185+
if (record == null) {
186+
return null;
187+
}
188+
189+
// the builder User.password(..) does not allow null values
190+
String apiPassword = record.getApiPassword();
191+
if (apiPassword == null) {
192+
apiPassword = "";
193+
}
194+
195+
return User
196+
.withUsername(record.getUsername())
197+
.password(apiPassword)
198+
.disabled(!record.getEnabled())
199+
.authorities(fromJson(record.getAuthorities()))
200+
.build();
201+
}
202+
156203
private WebUserRecord toWebUserRecord(UserDetails user) {
157204
return new WebUserRecord()
158205
.setUsername(user.getUsername())
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE web_user CHANGE COLUMN api_token api_password varchar(500) NULL;

0 commit comments

Comments
 (0)